File size: 33,208 Bytes
71e71e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
import datetime
from typing import Any
from zoneinfo import ZoneInfo

from peewee import *
from src.tg_compat import Update
from src.tg_compat.ext import ContextTypes

import constants as c
import resources.Environment as Env
from resources import phrases
from src.model.BaseModel import BaseModel
from src.model.Crew import Crew
from src.model.enums.ContextDataKey import ContextDataKey, ContextDataType
from src.model.enums.Location import (
    get_last_new_world,
    get_first_new_world,
    get_by_level,
    Location,
    is_paradise_by_level,
)
from src.model.enums.Screen import Screen
from src.model.enums.Language import Language, LANGUAGE_DEFAULT
from src.model.enums.crew.CrewRole import CrewRole
from src.model.enums.income_tax.IncomeTaxBracket import IncomeTaxBracket
from src.utils.context_utils import (
    get_user_context_data,
    set_user_context_data,
    remove_context_data,
)
from src.utils.math_utils import get_cumulative_percentage_sum


class User(BaseModel):
    """
    User class
    """

    tg_user_id: str | CharField = CharField(max_length=99, unique=True)
    tg_first_name: str | CharField = CharField(max_length=99)
    tg_last_name: str | CharField = CharField(max_length=99)
    tg_username: str | CharField = CharField(max_length=99)
    bounty: int | BigIntegerField = BigIntegerField(default=0)
    total_gained_bounty: int | BigIntegerField = BigIntegerField(
        default=0, constraints=[Check("total_gained_bounty >= 0")]
    )
    total_gained_bounty_unmodified: int | BigIntegerField = BigIntegerField(
        default=0, constraints=[Check("total_gained_bounty_unmodified >= 0")]
    )
    pending_bounty: int | BigIntegerField = BigIntegerField(default=0)
    doc_q_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    game_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    game_challenge_count: int | SmallIntegerField = SmallIntegerField(default=0)
    game_cooldown_start_time: datetime.datetime | DateTimeField = DateTimeField(null=True)
    game_accept_global_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(
        null=True
    )
    bounty_poster_limit: int | SmallIntegerField = SmallIntegerField(default=0)
    location_level: int | SmallIntegerField = SmallIntegerField(default=0)
    should_propose_new_world: bool | BooleanField = BooleanField(default=True)
    can_change_region: bool | BooleanField = BooleanField(default=True)
    fight_immunity_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    fight_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    plunder_immunity_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    plunder_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    impel_down_release_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    impel_down_is_permanent: bool | BooleanField = BooleanField(default=False)
    crew: Crew | ForeignKeyField = ForeignKeyField(Crew, backref="crew_members", null=True)
    crew_join_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    crew_role: int | SmallIntegerField = SmallIntegerField(null=True)
    crew_promotion_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    conscription_end_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    can_create_crew: bool | BooleanField = BooleanField(default=True)
    can_join_crew: bool | BooleanField = BooleanField(default=True)
    crew_davy_back_fight_priority: int | IntegerField = IntegerField(null=True)
    last_message_date: datetime.datetime | DateTimeField = DateTimeField(
        default=datetime.datetime.now
    )
    last_system_interaction_date: datetime.datetime | DateTimeField = DateTimeField(null=True)
    private_screen_list: str | CharField | None = CharField(max_length=99)
    private_screen_step: int | SmallIntegerField | None = SmallIntegerField(null=True)
    private_screen_in_edit_id: int | IntegerField | None = IntegerField(null=True)
    bounty_gift_tax: int | IntegerField = IntegerField(default=0)
    is_admin: bool | BooleanField = BooleanField(default=False)
    devil_fruit_collection_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(
        null=True
    )
    timezone: str | CharField = CharField(max_length=99, null=True)
    is_active: bool | BooleanField = BooleanField(default=True)
    prediction_creation_cooldown_end_date: datetime.datetime | DateTimeField = DateTimeField(
        null=True
    )
    bounty_loan_issue_cool_down_end_date: datetime.datetime | DateTimeField = DateTimeField(
        null=True
    )
    # For Legendary Pirate
    is_exempt_from_global_leaderboard_requirements: bool | BooleanField = BooleanField(
        default=False
    )
    can_collect_daily_reward: bool | BooleanField = BooleanField(default=True)
    fight_scout_count: int | IntegerField = IntegerField(default=0)
    plunder_scout_count: int | IntegerField = IntegerField(default=0)
    language: str | CharField = CharField(max_length=5, default=LANGUAGE_DEFAULT.value)

    # Transient fields
    # The private screen step with which the user arrived to the current screen
    private_screen_previous_step: int = None
    # If to stay on the same screen
    private_screen_stay: bool = False
    # Step to go back to
    private_screen_stay_step: int | None = None
    # If to force going back to previous screen
    private_screen_force_go_back: bool = False

    # If the model should be updated at script end. Sometimes the model is updated in functions
    # where it can not be passed as a parameter, so updating it at the end of the script would
    # overwrite the changes
    should_update_model: bool = True

    # Backref
    bounty_borrowers = None
    legendary_pirates = None
    warlords = None
    daily_bonuses = None

    class Meta:
        db_table = "user"

    def get_bounty_formatted(self) -> str:
        """
        Returns a formatted string of the bounty
        :return: The formatted string e.g. 1,000,000
        """

        from src.utils.string_utils import get_belly_formatted

        return get_belly_formatted(self.bounty)

    def is_arrested(self):
        """
        Returns True if the user is arrested
        :return: True if the user is arrested
        """

        return (
            self.impel_down_release_date is not None
            and self.impel_down_release_date > datetime.datetime.now()
        ) or self.impel_down_is_permanent

    def is_arrested_temporary(self):
        """
        Returns True if the user is temporarily arrested
        :return: True if the user is temporarily arrested
        """

        return self.is_arrested() and not self.impel_down_is_permanent

    def is_arrested_permanent(self):
        """
        Returns True if the user is permanently arrested
        :return: True if the user is permanently arrested
        """

        return self.is_arrested() and self.impel_down_is_permanent

    @staticmethod
    def get_is_not_arrested_statement_condition() -> Any:
        """
        Returns a case statement condition which returns true if the user is not arrested
        :return: The case statement
        """
        return (User.impel_down_is_permanent == False) & (
            (User.impel_down_release_date.is_null())
            | (User.impel_down_release_date < datetime.datetime.now())
        )

    def get_current_impel_down_log(self):
        """
        Returns the current impel down log
        :return: The current impel down log
        """

        from src.model.ImpelDownLog import ImpelDownLog

        return ImpelDownLog.get_current_for_user(self)

    def get_current_bail(self) -> int:
        """
        Get the bail for the user
        :return: The impel_down_log
        """
        current_impel_down_log = self.get_current_impel_down_log()
        if current_impel_down_log is None:
            return 0

        return current_impel_down_log.get_bail()

    def update_private_screen_list(
        self, screen: Screen, previous_screen_list: list[Screen] = None
    ):
        """
        Updates the private screen list
        :param screen: The screen
        :param previous_screen_list: The previous screen list
        """

        if previous_screen_list is not None and len(previous_screen_list) == 0:
            previous_screen_list = None

        if previous_screen_list is not None:
            self.private_screen_step = None  # Will have to update when Skip button is present
            self.private_screen_list = c.STANDARD_SPLIT_CHAR.join(previous_screen_list)

        if self.private_screen_list is None or screen is Screen.PVT_START:
            self.private_screen_list = screen
        else:
            private_screen_list = self.get_private_screen_list()

            if len(private_screen_list) == 0:
                self.private_screen_list = screen
                return

            # Back button, remove last screen
            if len(private_screen_list) > 2 and private_screen_list[-2] is screen:
                private_screen_list.pop()
            elif private_screen_list[-1] is not screen:  # Add screen
                private_screen_list.append(screen)

            self.private_screen_list = c.STANDARD_SPLIT_CHAR.join(private_screen_list)

        if len(self.private_screen_list) == 0:
            self.private_screen_list = None

    def in_edit_mode(self, current_screen=None):
        """
        Returns True if the user is in edit mode, i.e. is expected to send a text input
        as part of a multi-step flow (e.g. crew creation, prediction creation) or is editing
        an existing item.
        :param current_screen: The screen currently resolved for this request. Required to
        correctly detect edit mode based on private_screen_step, since that field is only
        meaningful while the user is actually on one of the screens that use it
        (see Screen.SCREENS_WITH_STEP). Without it, a stale/default private_screen_step value
        left on the user row would incorrectly be treated as "in edit mode" forever.
        :return: True if the user is in edit mode
        """

        from src.model.enums.Screen import SCREENS_WITH_STEP

        if self.private_screen_in_edit_id is not None:
            return True

        if self.private_screen_step is None:
            return False

        if current_screen is None:
            # Caller didn't provide the current screen, fall back to the saved one
            current_screen = self.get_current_private_screen()

        return current_screen in SCREENS_WITH_STEP

    def get_private_screen_list(self) -> list[Screen]:
        """
        Returns the private screen list
        :return: The private screen list
        """

        # OPTION 1 FIX: Safely check if string is completely empty or None
        if not self.private_screen_list:
            return []

        # OPTION 1 FIX: Filter out empty strings during the split loop
        return [
            Screen(str(screen)) 
            for screen in self.private_screen_list.split(c.STANDARD_SPLIT_CHAR) 
            if screen.strip()
        ]

    def get_current_private_screen(self) -> Screen:
        """
        Returns the current saved private screen
        :return: The private screen
        """

        return (self.get_private_screen_list() or [None])[-1]

    def reset_private_screen(self):
        """
        Resets the private screen
        """

        self.private_screen_step = None
        self.private_screen_in_edit_id = None
        self.private_screen_stay = False
        self.private_screen_stay_step = None

    def remove_last_private_screen(self):
        """
        Removes the last private screen
        """

        private_screen_list = self.get_private_screen_list()
        if len(private_screen_list) > 0:
            private_screen_list.pop()
            self.private_screen_list = c.STANDARD_SPLIT_CHAR.join(private_screen_list)

    def add_private_screen(self, screen: Screen):
        """
        Adds a private screen
        :param screen: The screen
        """

        private_screen_list = self.get_private_screen_list()
        private_screen_list.append(screen)
        self.private_screen_list = c.STANDARD_SPLIT_CHAR.join(private_screen_list)

    def replace_last_private_screen(self, screen: Screen):
        """
        Replaces the last private screen
        :param screen: The screen
        """

        self.remove_last_private_screen()
        self.add_private_screen(screen)

    def is_crew_captain(self):
        """
        Returns True if the user is the captain of a Crew
        :return: True if the user is the captain of a Crew
        """

        if self.is_crew_member() and self.crew_role is not None:
            return CrewRole(self.crew_role) is CrewRole.CAPTAIN

        return False

    def is_crew_first_mate(self):
        """
        Returns True if the user is the first mate of a Crew
        :return: True if the user is the first mate of a Crew
        """

        if self.is_crew_member() and self.crew_role is not None:
            return CrewRole(self.crew_role) is CrewRole.FIRST_MATE

        return False

    def is_crew_captain_or_first_mate(self):
        """
        Returns True if the user is the captain or first mate of a Crew
        :return: True if the user is the captain or first mate of a Crew
        """

        return self.is_crew_captain() or self.is_crew_first_mate()

    @staticmethod
    def get_by_tg_id(tg_user_id: str) -> "User":
        """
        Returns the User by the Telegram user id
        :param tg_user_id: The Telegram user id
        :return: The User
        """

        return User.get_or_none(User.tg_user_id == tg_user_id)

    def is_crew_member(self):
        """
        Returns True if the user is in a crew
        :return: True if the user is in a crew
        """

        return self.crew is not None

    def in_same_crew(self, user: "User") -> bool:
        """
        Returns True if the user is in the same crew as the given user
        :param user: The user
        :return: True if the user is in the same crew as the given user
        """

        return self.crew is not None and user is not None and self.crew == user.crew

    def get_markdown_mention(self) -> str:
        """
        Returns the markdown mention of the user
        :return: The markdown mention of the user
        """

        from src.service.message_service import mention_markdown_v2

        return mention_markdown_v2(self.tg_user_id, self.tg_first_name)

    def get_you_markdown_mention(self):
        """
        Returns the markdown mention of the user
        :return: The markdown mention of the user
        """

        from src.service.message_service import mention_markdown_v2

        return mention_markdown_v2(self.tg_user_id, phrases.TEXT_YOU)

    def get_markdown_name(self):
        """
        Returns the markdown name of the user
        :return: The markdown name of the user
        """

        from src.service.message_service import escape_valid_markdown_chars

        return escape_valid_markdown_chars(self.tg_first_name)

    def get_max_bounty(self) -> int:
        """
        Returns the max bounty between the user and the crew average
        :return: The max bounty between the user and the crew average
        """

        if self.is_crew_member():
            return max(self.bounty, self.crew.get_average_bounty())

        return self.bounty

    def in_new_world(self):
        """
        Returns True if the user is in the New World
        :return: True if the user is in the New World
        """

        from src.model.enums.Location import is_new_world_by_level

        return is_new_world_by_level(self.location_level)

    def has_higher_bounty_than_crew_average(self) -> bool:
        """
        Returns True if the user has a higher bounty than the crew average
        :return: True if the user has a higher bounty than the crew average
        """

        if self.is_crew_member():
            return self.bounty > self.crew.get_average_bounty()

        return False

    def has_new_world_bonus(self) -> bool:
        """
        Returns True if the user has the New World bonus
        :return: True if the user has the New World bonus
        """

        return self.in_new_world() and not self.has_bounty_gain_limitations()

    def has_crew_bonus(self) -> bool:
        """
        Returns True if the user has the Crew bonus
        :return: True if the user has the Crew bonus
        """

        return self.is_crew_member() and not self.has_bounty_gain_limitations()

    def has_crew_mvp_bonus(self) -> bool:
        """
        Returns True if the user has the Crew MVP bonus
        :return: True if the user has the Crew MVP bonus
        """

        return (
            self.has_higher_bounty_than_crew_average() and not self.has_bounty_gain_limitations()
        )

    def is_in_paradise(self) -> bool:
        """
        Returns True if the user is in Paradise
        :return: True if the user is in Paradise
        """

        return is_paradise_by_level(self.location_level)

    def get_location(self) -> Location:
        """
        Returns the location of the user
        :return: The location of the user
        """

        return get_by_level(self.location_level)

    def get_location_name(self) -> str:
        """
        Returns the location name of the user
        :return: The location name of the user
        """

        return self.get_location().name

    def has_bounty_gain_limitations(self) -> bool:
        """
        Returns True if the user has bounty gain limitations
        This occurs when the user is in Paradise but has enough bounty to be in the New World, or
        their bounty is higher than that required to reach the final location
        :return: True if the user has bounty gain limitations
        """

        return (
            self.is_in_paradise()
            and (self.bounty + self.pending_bounty) >= get_first_new_world().required_bounty
        ) or ((self.bounty + self.pending_bounty) >= get_last_new_world().required_bounty)

    @staticmethod
    def get_has_bounty_gain_limitations_statement_condition() -> Any:
        """
        Returns the has bounty gain limitations statement condition
        :return: The has bounty gain limitations statement condition
        """

        return (
            (User.location_level < get_first_new_world().level)
            & ((User.bounty + User.pending_bounty) >= get_first_new_world().required_bounty)
        ) | ((User.bounty + User.pending_bounty) >= get_last_new_world().required_bounty)

    def get_in_same_crew_statement_condition(self, other_user: "User") -> Any:
        """
        Returns the in same crew statement condition
        :param other_user: The other user
        :return: The in same crew statement condition
        """

        return (
            User.select(fn.COUNT(User.id)).where(
                (other_user.crew.is_null(False))
                & (other_user.crew == self.crew)
                & (other_user == User.id)
            )
            > 0
        )

    async def is_chat_admin(
        self, update: Update = None, context: ContextTypes.DEFAULT_TYPE = None, chat_id: str = None
    ) -> bool:
        """
        Returns True if the user is an admin of the chat
        :param update: The update
        :param context: The context
        :param chat_id: The chat id
        :return: True if the user is an admin of the chat
        """
        from src.service.user_service import user_is_chat_admin

        return await user_is_chat_admin(self, update=update, context=context, tg_group_id=chat_id)

    async def is_chat_member(self, context: ContextTypes.DEFAULT_TYPE, chat_id: str):
        """
        Returns True if the user is in the chat
        :param context: The context
        :param chat_id: The chat id
        :return: True if the user is in the chat
        """

        from src.service.user_service import user_is_chat_member

        return await user_is_chat_member(self, context=context, tg_group_id=chat_id)

    async def in_authorized_groups(self, context: ContextTypes.DEFAULT_TYPE):
        """
        Returns True if the user is in authorized groups
        :param context: The context
        :return: True if the user is in authorized groups
        """

        group_ids = Env.AUTHORIZED_GROUPS.get_list()
        for group_id in group_ids:
            if await self.is_chat_member(context, group_id):
                return True

        return False

    @staticmethod
    def get_context_data(
        context: ContextTypes.DEFAULT_TYPE,
        key: ContextDataKey,
        tolerate_key_exception: bool = True,
        inner_key: str = None,
    ) -> any:
        """
        Get user data from context
        :param context: The context
        :param key: The key
        :param tolerate_key_exception: If the key is not found, raise CommonChatException instead
        of KeyError
        :param inner_key: The inner key
        :return: The value
        """

        return get_user_context_data(
            context, key, tolerate_key_exception=tolerate_key_exception, inner_key=inner_key
        )

    @staticmethod
    def get_context_data_or_none(
        context: ContextTypes.DEFAULT_TYPE, key: ContextDataKey, inner_key: str = None
    ) -> any:
        """
        Get user data from context or None
        :param context: The context
        :param key: The key
        :param inner_key: The inner key
        :return: The value
        """
        try:
            return get_user_context_data(
                context, key, tolerate_key_exception=False, inner_key=inner_key
            )
        except KeyError:
            return None

    @staticmethod
    def set_context_data(
        context: ContextTypes.DEFAULT_TYPE, key: ContextDataKey, value: Any, inner_key: str = None
    ) -> Any:
        """
        Set user data to context
        :param context: The context
        :param key: The key
        :param value: The value
        :param inner_key: The inner key
        """

        return set_user_context_data(context, key, value, inner_key=inner_key)

    @staticmethod
    def remove_context_data(
        context: ContextTypes.DEFAULT_TYPE, key: ContextDataKey, inner_key: str = None
    ):
        """
        Remove user data from context
        :param context: The context
        :param key: The key
        :param inner_key: The inner key
        """

        try:
            remove_context_data(context, ContextDataType.USER, key, inner_key=inner_key)
        except KeyError:
            pass

    @staticmethod
    def clear_context_filters(context: ContextTypes.DEFAULT_TYPE):
        """
        Clear the context filters
        :param context: The context
        """

        # Clear context filters
        remove_context_data(context, ContextDataType.USER, ContextDataKey.FILTER)
        remove_context_data(context, ContextDataType.USER, ContextDataKey.INBOUND_KEYBOARD)

    def get_expired_bounty_loans(self) -> list:
        """
        Get the expired bounty loans
        :return: The expired bounty loans
        """

        from src.model.enums.BountyLoanStatus import BountyLoanStatus
        from src.model.BountyLoan import BountyLoan

        return self.bounty_borrowers.where(BountyLoan.status == BountyLoanStatus.EXPIRED).order_by(
            BountyLoan.date.asc()
        )

    def has_expired_bounty_loans(self) -> bool:
        """
        Returns True if the user has expired bounty loans
        :return: True if the user has expired bounty loans
        """

        return len(self.get_expired_bounty_loans()) > 0

    def get_expired_bounty_loans_cumulative_percentage(self) -> int:
        """
        Returns the cumulative percentage of the expired bounty loans
        :return: The cumulative percentage of the expired bounty loans
        """

        from src.utils.math_utils import format_percentage_value

        return format_percentage_value(
            get_cumulative_percentage_sum(
                [Env.BOUNTY_LOAN_GARNISH_PERCENTAGE.get_float()]
                * len(self.get_expired_bounty_loans())
            )
        )

    def get_bounty_plus_pending_bounty(self) -> int:
        """
        Returns the bounty plus the pending bounty
        :return: The bounty plus the pending bounty
        """

        return self.bounty + self.pending_bounty

    def has_income_tax(self):
        """
        Returns True if the user has income tax
        :return: True if the user has income tax
        """

        return self.get_income_tax_percentage() > 0

    def reduce_tax_bracket(self, levels_down: int = 1) -> None:
        """
        Reduces the tax bracket by the given amount
        :param levels_down: The number of levels down
        :return: None
        """

        # Get previous tax bracket
        previous_tax_bracket = IncomeTaxBracket.get_previous_bracket(
            self.total_gained_bounty, levels_down
        )

        # Update user
        self.total_gained_bounty = previous_tax_bracket.min_amount
        self.save()

    def get_income_tax_percentage(self) -> float:
        """
        Returns the income tax percentage
        :return: The income tax percentage
        """

        from src.utils.math_utils import format_percentage_value

        return format_percentage_value(
            IncomeTaxBracket.get_bracket(self.total_gained_bounty).percentage
        )

    @staticmethod
    def get_active_interactive_users() -> list:
        """
        Get the active users who interacted with the system
        :return: The active interactive users
        """

        return User.select().where(
            User.last_system_interaction_date
            > datetime.datetime.now()
            - datetime.timedelta(days=Env.INACTIVE_GROUP_USER_DAYS.get_int())
        )

    def get_timezone(self) -> ZoneInfo:
        """
        Returns the timezone of the user
        :return: The timezone of the user
        """

        return ZoneInfo(self.timezone or Env.TZ.get())

    def get_language(self) -> Language:
        """
        Returns the language of the user, used for texts sent in private chat
        :return: The language of the user
        """

        try:
            return Language(self.language)
        except ValueError:
            return LANGUAGE_DEFAULT

    def get_current_time(self) -> datetime.datetime:
        """
        Returns the current time of the user
        :return: The current time of the user
        """

        return datetime.datetime.now(self.get_timezone())

    def get_datetime_formatted(self, date: datetime.datetime) -> str:
        """
        Returns the formatted datetime of the user in their timezone

        :param date: The datetime
        :return: The formatted datetime of the user
        """

        from src.service.date_service import default_datetime_format

        return default_datetime_format(date, self)

    def get_date_formatted(self, date: datetime.datetime) -> str:
        """
        Returns the formatted date of the user in their timezone

        :param date: The date
        :return: The formatted date of the user
        """

        from src.service.date_service import default_date_format

        return default_date_format(date, self)

    def get_crew_role(self):
        """
        Returns the crew role
        :return: The crew role
        """
        return CrewRole(self.crew_role) if self.crew_role is not None else None

    def get_crew_role_description(self):
        """
        Returns the crew role description
        :return: The crew role description
        """

        return self.get_crew_role().get_description()

    def is_legendary_pirate(self) -> bool:
        """
        Returns True if the user is a Legendary Pirate
        :return: True if the user is a Legendary Pirate
        """
        from src.model.LegendaryPirate import LegendaryPirate, ensure_legendary_pirate_schema

        ensure_legendary_pirate_schema()
        now = datetime.datetime.now()
        return self.legendary_pirates.where(
            (LegendaryPirate.end_date.is_null()) | (LegendaryPirate.end_date > now)
        ).count() > 0

    def is_permanent_legendary_pirate(self) -> bool:
        """
        Returns True if the user is a permanent Legendary Pirate
        :return: True if the user is a permanent Legendary Pirate
        """
        from src.model.LegendaryPirate import LegendaryPirate, ensure_legendary_pirate_schema

        ensure_legendary_pirate_schema()
        now = datetime.datetime.now()
        return (
            self.legendary_pirates.where(
                (LegendaryPirate.is_permanent == True)
                & ((LegendaryPirate.end_date.is_null()) | (LegendaryPirate.end_date > now))
            ).count()
            > 0
        )

    def is_warlord(self) -> bool:
        """
        Returns True if the user is a Warlord
        :return: True if the user is a Warlord
        """
        from src.model.Warlord import Warlord

        return self.warlords.where(Warlord.end_date > datetime.datetime.now()).count() > 0

    def get_mention_url(self) -> str:
        """
        Returns the mention url of the user
        :return: The mention url of the user
        """

        return f"tg://user?id={self.tg_user_id}"

    def get_conscription_remaining_time(self) -> str:
        """
        Get the conscription remaining time
        :return: The conscription remaining time
        """
        from src.service.date_service import get_remaining_duration

        return get_remaining_duration(self.conscription_end_date)

    @staticmethod
    def reset_daily_reward():
        """
        Reset the daily reward
        :return: None
        """

        User.update(can_collect_daily_reward=True).execute()


User.create_table()

# Auto-migrate: add new challenge cooldown bucket columns if they don't exist yet
try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.add_column('user', 'game_challenge_count', SmallIntegerField(default=0)),
    )
except Exception:
    pass  # Column already exists

try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.add_column('user', 'game_cooldown_start_time', DateTimeField(null=True)),
    )
except Exception:
    pass  # Column already exists

# Auto-migrate: private_screen_step was originally created as NOT NULL with no default,
# causing new rows to default to 0 instead of NULL. Since 0 is a valid step value for some
# screens, this made User.in_edit_mode() always evaluate as True, which silently disabled
# the safeguard against replaying the last completed action on unrelated text messages.
# Safe to run on every startup: MODIFYing an already-nullable column to NULL is a no-op.
try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.drop_not_null('user', 'private_screen_step'),
    )
except Exception:
    pass  # Column already nullable or migration not needed

# Auto-migrate: composite index on (crew_role, conscription_end_date). Queried every minute by
# end_all_conscription (WHERE crew_role == CONSCRIPT AND conscription_end_date < NOW()) with no
# index, forcing a full table scan on the user table on every single run, 24/7.
try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.add_index("user", ("crew_role", "conscription_end_date"), unique=False),
    )
except Exception:
    pass  # Index already exists

# Auto-migrate: index on bounty. Leaderboard generation filters and ORDER BY's on this column
# with no index, forcing a full table scan + sort on every leaderboard run.
try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.add_index("user", ("bounty",), unique=False),
    )
except Exception:
    pass  # Index already exists

# Auto-migrate: add language column for the DM language setting
try:
    from playhouse.migrate import MySQLMigrator, migrate as pw_migrate
    from src.model.BaseModel import db_obj

    _migrator = MySQLMigrator(db_obj.get_db())
    pw_migrate(
        _migrator.add_column(
            "user", "language", CharField(max_length=5, default=LANGUAGE_DEFAULT.value)
        ),
    )
except Exception:
    pass  # Column already exists