models.py 46.5 KB
Newer Older
1
2
# -*- coding:utf-8 -*
#
Skia's avatar
Skia committed
3
# Copyright 2016,2017,2018
4
# - Skia <skia@libskia.so>
Sli's avatar
Sli committed
5
# - Sli <antoine@bartuccio.fr>
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
25
import importlib
26

Skia's avatar
Skia committed
27
from django.db import models
28
from django.core.mail import send_mail
Sli's avatar
Sli committed
29
30
31
32
33
34
35
36
from django.contrib.auth.models import (
    AbstractBaseUser,
    PermissionsMixin,
    UserManager,
    Group as AuthGroup,
    GroupManager as AuthGroupManager,
    AnonymousUser as AuthAnonymousUser,
)
Skia's avatar
Skia committed
37
38
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
39
from django.core import validators
Skia's avatar
Skia committed
40
from django.core.exceptions import ValidationError, PermissionDenied
41
from django.core.urlresolvers import reverse
42
from django.conf import settings
Skia's avatar
Skia committed
43
from django.db import transaction
44
45
from django.contrib.staticfiles.storage import staticfiles_storage
from django.utils.html import escape
46
47
from django.utils.functional import cached_property

Sli's avatar
Sli committed
48
49
import os

Skia's avatar
Skia committed
50
51
from phonenumber_field.modelfields import PhoneNumberField

52
from datetime import datetime, timedelta, date
Skia's avatar
Skia committed
53

Skia's avatar
Skia committed
54
55
import unicodedata

Krophil's avatar
Krophil committed
56

Skia's avatar
Skia committed
57
58
59
60
class RealGroupManager(AuthGroupManager):
    def get_queryset(self):
        return super(RealGroupManager, self).get_queryset().filter(is_meta=False)

Krophil's avatar
Krophil committed
61

Skia's avatar
Skia committed
62
63
64
65
class MetaGroupManager(AuthGroupManager):
    def get_queryset(self):
        return super(MetaGroupManager, self).get_queryset().filter(is_meta=True)

Krophil's avatar
Krophil committed
66

Skia's avatar
Skia committed
67
class Group(AuthGroup):
Skia's avatar
Skia committed
68
    is_meta = models.BooleanField(
Sli's avatar
Sli committed
69
        _("meta group status"),
Skia's avatar
Skia committed
70
        default=False,
Sli's avatar
Sli committed
71
        help_text=_("Whether a group is a meta group or not"),
Skia's avatar
Skia committed
72
    )
Sli's avatar
Sli committed
73
    description = models.CharField(_("description"), max_length=60)
74
75

    class Meta:
Sli's avatar
Sli committed
76
        ordering = ["name"]
77

Skia's avatar
Skia committed
78
79
80
81
    def get_absolute_url(self):
        """
        This is needed for black magic powered UpdateView's children
        """
Sli's avatar
Sli committed
82
        return reverse("core:group_list")
Skia's avatar
Skia committed
83

Krophil's avatar
Krophil committed
84

Skia's avatar
Skia committed
85
86
class MetaGroup(Group):
    objects = MetaGroupManager()
Krophil's avatar
Krophil committed
87

Skia's avatar
Skia committed
88
89
90
91
92
93
94
    class Meta:
        proxy = True

    def __init__(self, *args, **kwargs):
        super(MetaGroup, self).__init__(*args, **kwargs)
        self.is_meta = True

Krophil's avatar
Krophil committed
95

Skia's avatar
Skia committed
96
97
class RealGroup(Group):
    objects = RealGroupManager()
Krophil's avatar
Krophil committed
98

Skia's avatar
Skia committed
99
100
101
    class Meta:
        proxy = True

Krophil's avatar
Krophil committed
102

103
104
def validate_promo(value):
    start_year = settings.SITH_SCHOOL_START_YEAR
Krophil's avatar
Krophil committed
105
    delta = (date.today() + timedelta(days=180)).year - start_year
106
107
    if value < 0 or delta < value:
        raise ValidationError(
Sli's avatar
Sli committed
108
109
            _("%(value)s is not a valid promo (between 0 and %(end)s)"),
            params={"value": value, "end": delta},
110
111
        )

Krophil's avatar
Krophil committed
112

113
class User(AbstractBaseUser):
Skia's avatar
Skia committed
114
115
    """
    Defines the base user class, useable in every app
Skia's avatar
Skia committed
116

Skia's avatar
Skia committed
117
118
119
    This is almost the same as the auth module AbstractUser since it inherits from it,
    but some fields are required, and the username is generated automatically with the
    name of the user (see generate_username()).
Skia's avatar
Skia committed
120

121
    Added field: nick_name, date_of_birth
Skia's avatar
Skia committed
122
123
    Required fields: email, first_name, last_name, date_of_birth
    """
Sli's avatar
Sli committed
124

Skia's avatar
Skia committed
125
    username = models.CharField(
Sli's avatar
Sli committed
126
        _("username"),
Skia's avatar
Skia committed
127
128
        max_length=254,
        unique=True,
Sli's avatar
Sli committed
129
130
131
        help_text=_(
            "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
        ),
Skia's avatar
Skia committed
132
133
        validators=[
            validators.RegexValidator(
Sli's avatar
Sli committed
134
135
136
137
138
139
140
                r"^[\w.+-]+$",
                _(
                    "Enter a valid username. This value may contain only "
                    "letters, numbers "
                    "and ./+/-/_ characters."
                ),
            )
Skia's avatar
Skia committed
141
        ],
Sli's avatar
Sli committed
142
        error_messages={"unique": _("A user with that username already exists.")},
Skia's avatar
Skia committed
143
    )
Sli's avatar
Sli committed
144
145
146
147
148
    first_name = models.CharField(_("first name"), max_length=64)
    last_name = models.CharField(_("last name"), max_length=64)
    email = models.EmailField(_("email address"), unique=True)
    date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
    nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
Skia's avatar
Skia committed
149
    is_staff = models.BooleanField(
Sli's avatar
Sli committed
150
        _("staff status"),
Skia's avatar
Skia committed
151
        default=False,
Sli's avatar
Sli committed
152
        help_text=_("Designates whether the user can log into this admin site."),
Skia's avatar
Skia committed
153
154
    )
    is_active = models.BooleanField(
Sli's avatar
Sli committed
155
        _("active"),
Skia's avatar
Skia committed
156
157
        default=True,
        help_text=_(
Sli's avatar
Sli committed
158
159
            "Designates whether this user should be treated as active. "
            "Unselect this instead of deleting accounts."
Skia's avatar
Skia committed
160
161
        ),
    )
Sli's avatar
Sli committed
162
163
    date_joined = models.DateField(_("date joined"), auto_now_add=True)
    last_update = models.DateTimeField(_("last update"), auto_now=True)
164
    is_superuser = models.BooleanField(
Sli's avatar
Sli committed
165
        _("superuser"),
166
        default=False,
Sli's avatar
Sli committed
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
        help_text=_("Designates whether this user is a superuser. "),
    )
    groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
    home = models.OneToOneField(
        "SithFile",
        related_name="home_of",
        verbose_name=_("home"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    profile_pict = models.OneToOneField(
        "SithFile",
        related_name="profile_of",
        verbose_name=_("profile"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    avatar_pict = models.OneToOneField(
        "SithFile",
        related_name="avatar_of",
        verbose_name=_("avatar"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    scrub_pict = models.OneToOneField(
        "SithFile",
        related_name="scrub_of",
        verbose_name=_("scrub"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    sex = models.CharField(
        _("sex"),
        max_length=10,
        choices=[("MAN", _("Man")), ("WOMAN", _("Woman"))],
        default="MAN",
    )
    tshirt_size = models.CharField(
        _("tshirt size"),
        max_length=5,
        choices=[
            ("-", _("-")),
            ("XS", _("XS")),
            ("S", _("S")),
            ("M", _("M")),
            ("L", _("L")),
            ("XL", _("XL")),
            ("XXL", _("XXL")),
            ("XXXL", _("XXXL")),
        ],
        default="-",
    )
    role = models.CharField(
        _("role"),
        max_length=15,
        choices=[
            ("STUDENT", _("Student")),
            ("ADMINISTRATIVE", _("Administrative agent")),
            ("TEACHER", _("Teacher")),
            ("AGENT", _("Agent")),
            ("DOCTOR", _("Doctor")),
            ("FORMER STUDENT", _("Former student")),
            ("SERVICE", _("Service")),
        ],
        blank=True,
        default="",
    )
    department = models.CharField(
        _("department"),
        max_length=15,
        choices=settings.SITH_PROFILE_DEPARTMENTS,
        default="NA",
        blank=True,
    )
    dpt_option = models.CharField(
        _("dpt option"), max_length=32, blank=True, default=""
247
    )
Skia's avatar
Skia committed
248
249
250
    semester = models.CharField(_("semester"), max_length=5, blank=True, default="")
    quote = models.CharField(_("quote"), max_length=256, blank=True, default="")
    school = models.CharField(_("school"), max_length=80, blank=True, default="")
Sli's avatar
Sli committed
251
252
253
254
255
256
257
    promo = models.IntegerField(
        _("promo"), validators=[validate_promo], null=True, blank=True
    )
    forum_signature = models.TextField(
        _("forum signature"), max_length=256, blank=True, default=""
    )
    second_email = models.EmailField(_("second email address"), null=True, blank=True)
Skia's avatar
Skia committed
258
259
260
    phone = PhoneNumberField(_("phone"), null=True, blank=True)
    parent_phone = PhoneNumberField(_("parent phone"), null=True, blank=True)
    address = models.CharField(_("address"), max_length=128, blank=True, default="")
Sli's avatar
Sli committed
261
262
263
264
265
266
267
    parent_address = models.CharField(
        _("parent address"), max_length=128, blank=True, default=""
    )
    is_subscriber_viewable = models.BooleanField(
        _("is subscriber viewable"), default=True
    )
    godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
Skia's avatar
Skia committed
268
269
270

    objects = UserManager()

Sli's avatar
Sli committed
271
    USERNAME_FIELD = "username"
Skia's avatar
Skia committed
272
    # REQUIRED_FIELDS = ['email']
Skia's avatar
Skia committed
273

274
275
276
277
278
279
    def has_module_perms(self, package_name):
        return self.is_active

    def has_perm(self, perm, obj=None):
        return self.is_active and self.is_superuser

Skia's avatar
Skia committed
280
281
282
283
    def get_absolute_url(self):
        """
        This is needed for black magic powered UpdateView's children
        """
Sli's avatar
Sli committed
284
        return reverse("core:user_profile", kwargs={"user_id": self.pk})
Skia's avatar
Skia committed
285

Skia's avatar
Skia committed
286
    def __str__(self):
Skia's avatar
Skia committed
287
        return self.get_display_name()
Skia's avatar
Skia committed
288

Skia's avatar
Skia committed
289
290
291
    def to_dict(self):
        return self.__dict__

292
    @cached_property
293
294
295
    def was_subscribed(self):
        return self.subscriptions.exists()

296
    @cached_property
Skia's avatar
Skia committed
297
    def is_subscribed(self):
Sli's avatar
Sli committed
298
299
300
        s = self.subscriptions.filter(
            subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
        )
301
        return s.exists()
Skia's avatar
Skia committed
302

Skia's avatar
Skia committed
303
304
305
    _club_memberships = {}
    _group_names = {}
    _group_ids = {}
Krophil's avatar
Krophil committed
306

Skia's avatar
Skia committed
307
    def is_in_group(self, group_name):
Skia's avatar
Skia committed
308
        """If the user is in the group passed in argument (as string or by id)"""
Skia's avatar
Skia committed
309
        group_id = 0
Skia's avatar
Skia committed
310
        g = None
Krophil's avatar
Krophil committed
311
        if isinstance(group_name, int):  # Handle the case where group_name is an ID
Skia's avatar
Skia committed
312
313
314
315
316
            if group_name in User._group_ids.keys():
                g = User._group_ids[group_name]
            else:
                g = Group.objects.filter(id=group_name).first()
                User._group_ids[group_name] = g
Skia's avatar
Skia committed
317
        else:
Skia's avatar
Skia committed
318
319
320
321
322
            if group_name in User._group_names.keys():
                g = User._group_names[group_name]
            else:
                g = Group.objects.filter(name=group_name).first()
                User._group_names[group_name] = g
Skia's avatar
Skia committed
323
324
325
326
327
        if g:
            group_name = g.name
            group_id = g.id
        else:
            return False
Skia's avatar
Skia committed
328
        if group_id == settings.SITH_GROUP_PUBLIC_ID:
Skia's avatar
Skia committed
329
            return True
330
        if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
331
            return self.is_subscribed
332
        if group_id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:
333
            return self.was_subscribed
Sli's avatar
Sli committed
334
335
336
        if (
            group_name == settings.SITH_MAIN_MEMBERS_GROUP
        ):  # We check the subscription if asked
337
            return self.is_subscribed
Sli's avatar
Sli committed
338
339
        if group_name[-len(settings.SITH_BOARD_SUFFIX) :] == settings.SITH_BOARD_SUFFIX:
            name = group_name[: -len(settings.SITH_BOARD_SUFFIX)]
Skia's avatar
Skia committed
340
341
342
343
            if name in User._club_memberships.keys():
                mem = User._club_memberships[name]
            else:
                from club.models import Club
Sli's avatar
Sli committed
344

Skia's avatar
Skia committed
345
346
347
                c = Club.objects.filter(unix_name=name).first()
                mem = c.get_membership_for(self)
                User._club_memberships[name] = mem
Skia's avatar
Skia committed
348
349
350
            if mem:
                return mem.role > settings.SITH_MAXIMUM_FREE_ROLE
            return False
Sli's avatar
Sli committed
351
352
353
354
355
        if (
            group_name[-len(settings.SITH_MEMBER_SUFFIX) :]
            == settings.SITH_MEMBER_SUFFIX
        ):
            name = group_name[: -len(settings.SITH_MEMBER_SUFFIX)]
Skia's avatar
Skia committed
356
357
358
359
            if name in User._club_memberships.keys():
                mem = User._club_memberships[name]
            else:
                from club.models import Club
Sli's avatar
Sli committed
360

Skia's avatar
Skia committed
361
362
363
                c = Club.objects.filter(unix_name=name).first()
                mem = c.get_membership_for(self)
                User._club_memberships[name] = mem
Skia's avatar
Skia committed
364
365
366
            if mem:
                return True
            return False
Skia's avatar
Skia committed
367
        if group_id == settings.SITH_GROUP_ROOT_ID and self.is_superuser:
Skia's avatar
Skia committed
368
            return True
Skia's avatar
Skia committed
369
370
371
372
373
        return group_name in self.cached_groups_names

    @cached_property
    def cached_groups_names(self):
        return [g.name for g in self.groups.all()]
Skia's avatar
Skia committed
374

375
    @cached_property
Skia's avatar
Skia committed
376
    def is_root(self):
Sli's avatar
Sli committed
377
378
379
380
        return (
            self.is_superuser
            or self.groups.filter(id=settings.SITH_GROUP_ROOT_ID).exists()
        )
Skia's avatar
Skia committed
381

382
    @cached_property
Sli's avatar
Sli committed
383
    def is_board_member(self):
384
        from club.models import Club
Sli's avatar
Sli committed
385
386
387
388
389
390

        return (
            Club.objects.filter(unix_name=settings.SITH_MAIN_CLUB["unix_name"])
            .first()
            .has_rights_in_club(self)
        )
391
392
393
394

    @cached_property
    def can_create_subscription(self):
        from club.models import Club
Sli's avatar
Sli committed
395
396
397
398

        for club in Club.objects.filter(
            id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS
        ).all():
399
400
401
            if club.has_rights_in_club(self):
                return True
        return False
Sli's avatar
Sli committed
402

403
    @cached_property
Sli's avatar
Sli committed
404
    def is_launderette_manager(self):
Sli's avatar
Sli committed
405
        from club.models import Club
Sli's avatar
Sli committed
406
407
408
409
410
411
412
413

        return (
            Club.objects.filter(
                unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
            )
            .first()
            .get_membership_for(self)
        )
414

415
    @cached_property
416
    def is_banned_alcohol(self):
Skia's avatar
Skia committed
417
        return self.is_in_group(settings.SITH_GROUP_BANNED_ALCOHOL_ID)
418

419
    @cached_property
420
    def is_banned_counter(self):
Skia's avatar
Skia committed
421
        return self.is_in_group(settings.SITH_GROUP_BANNED_COUNTER_ID)
422

Skia's avatar
Skia committed
423
    def save(self, *args, **kwargs):
424
        create = False
Skia's avatar
Skia committed
425
426
427
        with transaction.atomic():
            if self.id:
                old = User.objects.filter(id=self.id).first()
Skia's avatar
Skia committed
428
                if old and old.username != self.username:
Skia's avatar
Skia committed
429
                    self._change_username(self.username)
430
431
            else:
                create = True
Skia's avatar
Skia committed
432
            super(User, self).save(*args, **kwargs)
Sli's avatar
Sli committed
433
434
435
            if (
                create and settings.IS_OLD_MYSQL_PRESENT
            ):  # Create user on the old site: TODO remove me!
436
                import MySQLdb
Sli's avatar
Sli committed
437

438
439
440
                try:
                    db = MySQLdb.connect(**settings.OLD_MYSQL_INFOS)
                    c = db.cursor()
Sli's avatar
Sli committed
441
442
443
444
445
446
447
448
449
450
451
452
                    c.execute(
                        """INSERT INTO utilisateurs (id_utilisateur, nom_utl, prenom_utl, email_utl, hash_utl, ae_utl) VALUES
                    (%s, %s, %s, %s, %s, %s)""",
                        (
                            self.id,
                            self.last_name,
                            self.first_name,
                            self.email,
                            "valid",
                            "0",
                        ),
                    )
453
454
                    db.commit()
                except Exception as e:
Krophil's avatar
Krophil committed
455
                    with open(settings.BASE_DIR + "/user_fail.log", "a") as f:
Sli's avatar
Sli committed
456
457
458
459
460
                        print(
                            "FAIL to add user %s (%s %s - %s) to old site"
                            % (self.id, self.first_name, self.last_name, self.email),
                            file=f,
                        )
461
462
                        print("Reason: %s" % (repr(e)), file=f)
                    db.rollback()
Skia's avatar
Skia committed
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481

    def make_home(self):
        if self.home is None:
            home_root = SithFile.objects.filter(parent=None, name="users").first()
            if home_root is not None:
                home = SithFile(parent=home_root, name=self.username, owner=self)
                home.save()
                self.home = home
                self.save()

    def _change_username(self, new_name):
        u = User.objects.filter(username=new_name).first()
        if u is None:
            if self.home:
                self.home.name = new_name
                self.home.save()
        else:
            raise ValidationError(_("A user with that username already exists"))

Skia's avatar
Skia committed
482
483
484
485
486
487
488
489
    def get_profile(self):
        return {
            "last_name": self.last_name,
            "first_name": self.first_name,
            "nick_name": self.nick_name,
            "date_of_birth": self.date_of_birth,
        }

Skia's avatar
Skia committed
490
491
492
493
    def get_full_name(self):
        """
        Returns the first_name plus the last_name, with a space in between.
        """
Sli's avatar
Sli committed
494
        full_name = "%s %s" % (self.first_name, self.last_name)
Skia's avatar
Skia committed
495
496
497
498
        return full_name.strip()

    def get_short_name(self):
        "Returns the short name for the user."
Skia's avatar
Skia committed
499
500
501
        if self.nick_name:
            return self.nick_name
        return self.first_name + " " + self.last_name
Skia's avatar
Skia committed
502

Skia's avatar
Skia committed
503
504
505
506
507
    def get_display_name(self):
        """
        Returns the display name of the user.
        A nickname if possible, otherwise, the full name
        """
Skia's avatar
Skia committed
508
509
        if self.nick_name:
            return "%s (%s)" % (self.get_full_name(), self.nick_name)
Skia's avatar
Skia committed
510
511
        return self.get_full_name()

512
513
514
515
    def get_age(self):
        """
        Returns the age
        """
Sli's avatar
Sli committed
516
517
        today = timezone.now()
        born = self.date_of_birth
Sli's avatar
Sli committed
518
519
520
        return (
            today.year - born.year - ((today.month, today.day) < (born.month, born.day))
        )
521

Skia's avatar
Skia committed
522
523
524
525
    def email_user(self, subject, message, from_email=None, **kwargs):
        """
        Sends an email to this User.
        """
526
527
        if from_email is None:
            from_email = settings.DEFAULT_FROM_EMAIL
Skia's avatar
Skia committed
528
529
530
531
532
533
534
535
        send_mail(subject, message, from_email, [self.email], **kwargs)

    def generate_username(self):
        """
        Generates a unique username based on the first and last names.
        For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists
        Returns the generated username
        """
Sli's avatar
Sli committed
536

Skia's avatar
Skia committed
537
        def remove_accents(data):
Sli's avatar
Sli committed
538
539
540
541
542
543
544
545
546
547
548
            return "".join(
                x
                for x in unicodedata.normalize("NFKD", data)
                if unicodedata.category(x)[0] == "L"
            ).lower()

        user_name = (
            remove_accents(self.first_name[0] + self.last_name)
            .encode("ascii", "ignore")
            .decode("utf-8")
        )
Skia's avatar
Skia committed
549
550
551
        un_set = [u.username for u in User.objects.all()]
        if user_name in un_set:
            i = 1
Krophil's avatar
Krophil committed
552
            while user_name + str(i) in un_set:
Skia's avatar
Skia committed
553
554
555
556
                i += 1
            user_name += str(i)
        self.username = user_name
        return user_name
Skia's avatar
Skia committed
557

Skia's avatar
Skia committed
558
559
560
561
    def is_owner(self, obj):
        """
        Determine if the object is owned by the user
        """
Skia's avatar
Skia committed
562
563
        if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
            return True
Skia's avatar
Skia committed
564
        if hasattr(obj, "owner_group") and self.is_in_group(obj.owner_group.name):
Skia's avatar
Skia committed
565
            return True
Skia's avatar
Skia committed
566
        if self.is_superuser or self.is_in_group(settings.SITH_GROUP_ROOT_ID):
Skia's avatar
Skia committed
567
            return True
Skia's avatar
Skia committed
568
569
570
571
572
573
        return False

    def can_edit(self, obj):
        """
        Determine if the object can be edited by the user
        """
Skia's avatar
Skia committed
574
        if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
Skia's avatar
Skia committed
575
            return True
Skia's avatar
Skia committed
576
577
        if hasattr(obj, "edit_groups"):
            for g in obj.edit_groups.all():
Skia's avatar
Skia committed
578
                if self.is_in_group(g.name):
579
                    return True
580
581
        if isinstance(obj, User) and obj == self:
            return True
Skia's avatar
Skia committed
582
        if self.is_owner(obj):
Skia's avatar
Skia committed
583
            return True
Skia's avatar
Skia committed
584
585
586
587
588
589
        return False

    def can_view(self, obj):
        """
        Determine if the object can be viewed by the user
        """
Skia's avatar
Skia committed
590
        if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
Skia's avatar
Skia committed
591
            return True
Skia's avatar
Skia committed
592
593
        if hasattr(obj, "view_groups"):
            for g in obj.view_groups.all():
Skia's avatar
Skia committed
594
                if self.is_in_group(g.name):
595
                    return True
Skia's avatar
Skia committed
596
        if self.can_edit(obj):
Skia's avatar
Skia committed
597
            return True
Skia's avatar
Skia committed
598
599
        return False

Skia's avatar
Skia committed
600
    def can_be_edited_by(self, user):
Skia's avatar
Skia committed
601
        return user.is_in_group(settings.SITH_MAIN_BOARD_GROUP) or user.is_root
Skia's avatar
Skia committed
602

Skia's avatar
Skia committed
603
    def can_be_viewed_by(self, user):
Skia's avatar
Skia committed
604
        return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
Skia's avatar
Skia committed
605

606
607
608
609
610
611
612
613
614
    def get_mini_item(self):
        return """
    <div class="mini_profile_link" >
    <span>
    <img src="%s" alt="%s" />
    </span>
    <em>%s</em>
    </a>
    """ % (
Sli's avatar
Sli committed
615
616
617
            self.profile_pict.get_download_url()
            if self.profile_pict
            else staticfiles_storage.url("core/img/unknown.jpg"),
618
            _("Profile"),
619
            escape(self.get_display_name()),
Krophil's avatar
Krophil committed
620
        )
621

622
    @cached_property
Skia's avatar
Skia committed
623
624
    def subscribed(self):
        return self.is_in_group(settings.SITH_MAIN_MEMBERS_GROUP)
Skia's avatar
Skia committed
625

626
627
628
629
630
631
632
633
634
    @cached_property
    def preferences(self):
        try:
            return self._preferences
        except:
            prefs = Preferences(user=self)
            prefs.save()
            return prefs

635
    @cached_property
Skia's avatar
Skia committed
636
637
638
639
640
    def forum_infos(self):
        try:
            return self._forum_infos
        except:
            from forum.models import ForumUserInfo
Sli's avatar
Sli committed
641

Skia's avatar
Skia committed
642
643
644
645
            infos = ForumUserInfo(user=self)
            infos.save()
            return infos

Nicolas Ballet's avatar
Nicolas Ballet committed
646
647
    @cached_property
    def clubs_with_rights(self):
Sli's avatar
Sli committed
648
649
650
651
652
653
654
        return [
            m.club.id
            for m in self.memberships.filter(
                models.Q(end_date__isnull=True) | models.Q(end_date__gte=timezone.now())
            ).all()
            if m.club.has_rights_in_club(self)
        ]
655

Nicolas Ballet's avatar
Nicolas Ballet committed
656
657
658
659
    @cached_property
    def is_com_admin(self):
        return self.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID)

Krophil's avatar
Krophil committed
660

661
662
663
664
class AnonymousUser(AuthAnonymousUser):
    def __init__(self, request):
        super(AnonymousUser, self).__init__()

665
666
667
668
    @property
    def can_create_subscription(self):
        return False

Sli's avatar
Sli committed
669
    @property
Skia's avatar
Skia committed
670
    def was_subscribed(self):
Skia's avatar
Skia committed
671
        return False
Skia's avatar
Skia committed
672

Sli's avatar
Sli committed
673
674
675
676
    @property
    def is_subscribed(self):
        return False

Skia's avatar
Skia committed
677
678
    @property
    def subscribed(self):
Skia's avatar
Skia committed
679
        return False
Skia's avatar
Skia committed
680

Skia's avatar
Skia committed
681
682
    @property
    def is_root(self):
Skia's avatar
Skia committed
683
        return False
Skia's avatar
Skia committed
684

685
686
    @property
    def is_board_member(self):
Skia's avatar
Skia committed
687
        return False
688
689
690

    @property
    def is_launderette_manager(self):
Skia's avatar
Skia committed
691
        return False
692

693
694
    @property
    def is_banned_alcohol(self):
Skia's avatar
Skia committed
695
        return False
Skia's avatar
Skia committed
696
697

    @property
Skia's avatar
Skia committed
698
    def is_banned_counter(self):
Skia's avatar
Skia committed
699
        return False
700

Skia's avatar
Skia committed
701
702
703
704
    @property
    def forum_infos(self):
        raise PermissionDenied

Skia's avatar
Skia committed
705
706
707
708
    @property
    def favorite_topics(self):
        raise PermissionDenied

709
710
711
712
    def is_in_group(self, group_name):
        """
        The anonymous user is only the public group
        """
Skia's avatar
Skia committed
713
        group_id = 0
Krophil's avatar
Krophil committed
714
        if isinstance(group_name, int):  # Handle the case where group_name is an ID
Skia's avatar
Skia committed
715
716
717
718
719
720
721
            g = Group.objects.filter(id=group_name).first()
            if g:
                group_name = g.name
                group_id = g.id
            else:
                return False
        if group_id == settings.SITH_GROUP_PUBLIC_ID:
722
723
724
            return True
        return False

725
726
727
728
729
730
731
    def is_owner(self, obj):
        return False

    def can_edit(self, obj):
        return False

    def can_view(self, obj):
Sli's avatar
Sli committed
732
733
734
735
        if (
            hasattr(obj, "view_groups")
            and obj.view_groups.filter(id=settings.SITH_GROUP_PUBLIC_ID).exists()
        ):
Skia's avatar
Skia committed
736
            return True
Sli's avatar
Sli committed
737
        if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
738
739
740
            return True
        return False

741
742
743
    def get_display_name(self):
        return _("Visitor")

Krophil's avatar
Krophil committed
744

745
class Preferences(models.Model):
746
    user = models.OneToOneField(User, related_name="_preferences")
Skia's avatar
Skia committed
747
    receive_weekmail = models.BooleanField(
Sli's avatar
Sli committed
748
        _("do you want to receive the weekmail"), default=False
749
    )
Sli's avatar
Sli committed
750
    show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
751
    notify_on_click = models.BooleanField(
Sli's avatar
Sli committed
752
        _("get a notification for every click"), default=False
753
754
    )
    notify_on_refill = models.BooleanField(
Sli's avatar
Sli committed
755
        _("get a notification for every refilling"), default=False
756
757
    )

Skia's avatar
Skia committed
758
759
760
761
762
763
    def get_display_name(self):
        return self.user.get_display_name()

    def get_absolute_url(self):
        return self.user.get_absolute_url()

Krophil's avatar
Krophil committed
764

Skia's avatar
Skia committed
765
def get_directory(instance, filename):
Sli's avatar
Sli committed
766
    return ".{0}/{1}".format(instance.get_parent_path(), filename)
Skia's avatar
Skia committed
767

Krophil's avatar
Krophil committed
768

Skia's avatar
Skia committed
769
def get_compressed_directory(instance, filename):
Sli's avatar
Sli committed
770
    return "./.compressed/{0}/{1}".format(instance.get_parent_path(), filename)
Skia's avatar
Skia committed
771

Krophil's avatar
Krophil committed
772

Skia's avatar
Skia committed
773
def get_thumbnail_directory(instance, filename):
Sli's avatar
Sli committed
774
    return "./.thumbnails/{0}/{1}".format(instance.get_parent_path(), filename)
Skia's avatar
Skia committed
775

Krophil's avatar
Krophil committed
776

Skia's avatar
Skia committed
777
class SithFile(models.Model):
Sli's avatar
Sli committed
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
    name = models.CharField(_("file name"), max_length=256, blank=False)
    parent = models.ForeignKey(
        "self", related_name="children", verbose_name=_("parent"), null=True, blank=True
    )
    file = models.FileField(
        upload_to=get_directory,
        verbose_name=_("file"),
        max_length=256,
        null=True,
        blank=True,
    )
    compressed = models.FileField(
        upload_to=get_compressed_directory,
        verbose_name=_("compressed file"),
        max_length=256,
        null=True,
        blank=True,
    )
    thumbnail = models.FileField(
        upload_to=get_thumbnail_directory,
        verbose_name=_("thumbnail"),
        max_length=256,
        null=True,
        blank=True,
    )
Skia's avatar
Skia committed
803
    owner = models.ForeignKey(User, related_name="owned_files", verbose_name=_("owner"))
Sli's avatar
Sli committed
804
805
806
807
808
809
    edit_groups = models.ManyToManyField(
        Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
    )
    view_groups = models.ManyToManyField(
        Group, related_name="viewable_files", verbose_name=_("view group"), blank=True
    )
Skia's avatar
Skia committed
810
    is_folder = models.BooleanField(_("is folder"), default=True)
Sli's avatar
Sli committed
811
    mime_type = models.CharField(_("mime type"), max_length=30)
Skia's avatar
Skia committed
812
    size = models.IntegerField(_("size"), default=0)
Sli's avatar
Sli committed
813
    date = models.DateTimeField(_("date"), default=timezone.now)
Skia's avatar
Skia committed
814
    is_moderated = models.BooleanField(_("is moderated"), default=False)
Sli's avatar
Sli committed
815
816
817
818
819
820
821
    moderator = models.ForeignKey(
        User,
        related_name="moderated_files",
        verbose_name=_("owner"),
        null=True,
        blank=True,
    )
822
    asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
Sli's avatar
Sli committed
823
824
825
    is_in_sas = models.BooleanField(
        _("is in the SAS"), default=False
    )  # Allows to query this flag, updated at each call to save()
Skia's avatar
Skia committed
826
827
828
829
830

    class Meta:
        verbose_name = _("file")

    def is_owned_by(self, user):
Sli's avatar
Sli committed
831
832
833
        if hasattr(self, "profile_of") and user.is_in_group(
            settings.SITH_MAIN_BOARD_GROUP
        ):
Skia's avatar
Skia committed
834
            return True
Skia's avatar
Skia committed
835
        if user.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID):
Skia's avatar
Skia committed
836
            return True
Skia's avatar
Skia committed
837
838
        if self.is_in_sas and user.is_in_group(settings.SITH_GROUP_SAS_ADMIN_ID):
            return True
Skia's avatar
Skia committed
839
840
        return user.id == self.owner.id

841
    def can_be_viewed_by(self, user):
Sli's avatar
Sli committed
842
        if hasattr(self, "profile_of"):
843
            return user.can_view(self.profile_of)
Sli's avatar
Sli committed
844
        if hasattr(self, "avatar_of"):
845
            return user.can_view(self.avatar_of)
Sli's avatar
Sli committed
846
        if hasattr(self, "scrub_of"):
847
848
849
            return user.can_view(self.scrub_of)
        return False

Skia's avatar
Skia committed
850
851
852
853
    def delete(self):
        for c in self.children.all():
            c.delete()
        self.file.delete()
Skia's avatar
Skia committed
854
855
856
857
        if self.compressed:
            self.compressed.delete()
        if self.thumbnail:
            self.thumbnail.delete()
Skia's avatar
Skia committed
858
859
860
861
862
863
864
        return super(SithFile, self).delete()

    def clean(self):
        """
        Cleans up the file
        """
        super(SithFile, self).clean()
Sli's avatar
Sli committed
865
        if "/" in self.name:
Skia's avatar
Skia committed
866
867
            raise ValidationError(_("Character '/' not authorized in name"))
        if self == self.parent:
Sli's avatar
Sli committed
868
869
870
871
872
            raise ValidationError(_("Loop in folder tree"), code="loop")
        if self == self.parent or (
            self.parent is not None and self in self.get_parent_list()
        ):
            raise ValidationError(_("Loop in folder tree"), code="loop")
Skia's avatar
Skia committed
873
874
        if self.parent and self.parent.is_file:
            raise ValidationError(
Sli's avatar
Sli committed
875
                _("You can not make a file be a children of a non folder file")
Skia's avatar
Skia committed
876
            )
Sli's avatar
Sli committed
877
878
879
880
881
882
883
884
885
886
        if (
            self.parent is None
            and SithFile.objects.exclude(id=self.id)
            .filter(parent=None, name=self.name)
            .exists()
        ) or (
            self.parent
            and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()
        ):
            raise ValidationError(_("Duplicate file"), code="duplicate")
Skia's avatar
Skia committed
887
        if self.is_folder:
Skia's avatar
Skia committed
888
889
890
            if self.file:
                try:
                    import imghdr
Sli's avatar
Sli committed
891
892
893
894
895
896

                    if imghdr.what(None, self.file.read()) not in [
                        "gif",
                        "png",
                        "jpeg",
                    ]:
Skia's avatar
Skia committed
897
898
899
900
                        self.file.delete()
                        self.file = None
                except:
                    self.file = None
Skia's avatar
Skia committed
901
902
903
904
905
            self.mime_type = "inode/directory"
        if self.is_file and (self.file is None or self.file == ""):
            raise ValidationError(_("You must provide a file"))

    def save(self, *args, **kwargs):
906
907
        sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
        self.is_in_sas = sas in self.get_parent_list()
Skia's avatar
Skia committed
908
909
910
911
912
913
        copy_rights = False
        if self.id is None:
            copy_rights = True
        super(SithFile, self).save(*args, **kwargs)
        if copy_rights:
            self.copy_rights()
914
        if self.is_in_sas:
Sli's avatar
Sli committed
915
916
917
918
919
920
921
922
923
924
925
            for u in (
                RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
                .first()
                .users.all()
            ):
                Notification(
                    user=u,
                    url=reverse("sas:moderation"),
                    type="SAS_MODERATION",
                    param="1",
                ).save()
Skia's avatar
Skia committed
926

927
928
929
930
931
932
933
934
    def apply_rights_recursively(self, only_folders=False):
        children = self.children.all()
        if only_folders:
            children = children.filter(is_folder=True)
        for c in children:
            c.copy_rights()
            c.apply_rights_recursively(only_folders)

Skia's avatar
Skia committed
935
936
937
938
939
940
941
    def copy_rights(self):
        """Copy, if possible, the rights of the parent folder"""
        if self.parent is not None:
            self.edit_groups = self.parent.edit_groups.all()
            self.view_groups = self.parent.view_groups.all()
            self.save()

Skia's avatar
Skia committed
942
    def move_to(self, parent):
943
944
945
946
947
948
949
        """
        Move a file to a new parent.
        `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change
        anything.
        This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify
        SithFiles recursively, so it stays efficient even with top-level folders.
        """
Skia's avatar
Skia committed
950
951
        if not parent.is_folder:
            return
952
953
954
955
956
957
958
959
960
961
962
963
964
        self.parent = parent
        self.clean()
        self.save()

    def _repair_fs(self):
        """
        This function rebuilds recursively the filesystem as it should be
        regarding the DB tree.
        """
        if self.is_folder:
            for c in self.children.all():
                c._repair_fs()
            return
965
        elif not self._check_path_consistence():
966
967
968
969
970
971
972
            # First get future parent path and the old file name
            # Prepend "." so that we match all relative handling of Django's
            # file storage
            parent_path = "." + self.parent.get_full_path()
            parent_full_path = settings.MEDIA_ROOT + parent_path
            print("Parent full path: %s" % parent_full_path)
            os.makedirs(parent_full_path, exist_ok=True)
Sli's avatar
Sli committed
973
            old_path = self.file.name  # Should be relative: "./users/skia/bleh.jpg"
974
975
976
            new_path = "." + self.get_full_path()
            print("Old path: %s " % old_path)
            print("New path: %s " % new_path)
Skia's avatar
Skia committed
977
978
979
980
981
982
983
984
985
            try:
                # Make this atomic, so that a FS problem rolls back the DB change
                with transaction.atomic():
                    # Set the new filesystem path
                    self.file.name = new_path
                    self.save()
                    print("New file path: %s " % self.file.path)
                    # Really move at the FS level
                    if os.path.exists(parent_full_path):
Sli's avatar
Sli committed
986
987
988
989
                        os.rename(
                            settings.MEDIA_ROOT + old_path,
                            settings.MEDIA_ROOT + new_path,
                        )
Skia's avatar
Skia committed
990
991
992
993
994
995
                        # Empty directories may remain, but that's not really a
                        # problem, and that can be solved with a simple shell
                        # command: `find . -type d -empty -delete`
            except Exception as e:
                print("This file likely had a problem. Here is the exception:")
                print(repr(e))
Sli's avatar
Sli committed
996
            print("-" * 80)
997
998
999

    def _check_path_consistence(self):
        file_path = str(self.file)
Skia's avatar
Skia committed
1000
        file_full_path = settings.MEDIA_ROOT + file_path
1001
        db_path = ".%s" % self.get_full_path()
Skia's avatar
Skia committed
1002
1003
        if not os.path.exists(file_full_path):
            print("%s: WARNING: real file does not exists!" % self.id)
Sli's avatar
Sli committed
1004
            print("file path: %s" % file_path, end="")
Skia's avatar
Skia committed
1005
1006
            print("  db path: %s" % db_path)
            return False
1007
        if file_path != db_path:
Sli's avatar
Sli committed
1008
1009
            print("%s: " % self.id, end="")
            print("file path: %s" % file_path, end="")
1010
1011
            print("  db path: %s" % db_path)
            return False
1012
1013
        print("%s OK (%s)" % (self.id, file_path))
        return True
1014
1015
1016
1017
1018
1019
1020
1021

    def _check_fs(self):
        if self.is_folder:
            for c in self.children.all():
                c._check_fs()
            return
        else:
            self._check_path_consistence()
Skia's avatar
Skia committed
1022

Skia's avatar
Skia committed
1023
1024
1025
1026
    def __getattribute__(self, attr):
        if attr == "is_file":
            return not self.is_folder
        else:
Skia's avatar
Skia committed
1027
            return super(SithFile, self).__getattribute__(attr)
Skia's avatar
Skia committed
1028

1029
    @cached_property
Skia's avatar
Skia committed
1030
1031
    def as_picture(self):
        from sas.models import Picture
Sli's avatar
Sli committed
1032

Skia's avatar
Skia committed
1033
1034
        return Picture.objects.filter(id=self.id).first()

1035
    @cached_property
1036
1037
    def as_album(self):
        from sas.models import Album
Sli's avatar
Sli committed
1038

1039
1040
        return Album.objects.filter(id=self.id).first()

Skia's avatar
Skia committed
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
    def __str__(self):
        if self.is_folder:
            return _("Folder: ") + self.name
        else:
            return _("File: ") + self.name

    def get_parent_list(self):
        l = []
        p = self.parent
        while p is not None:
            l.append(p)
            p = p.parent
        return l

    def get_parent_path(self):
Sli's avatar
Sli committed
1056
        return "/" + "/".join([p.name for p in self.get_parent_list()[::-1]])
Skia's avatar
Skia committed
1057

Skia's avatar
Skia committed
1058
    def get_full_path(self):
Sli's avatar
Sli committed
1059
        return self.get_parent_path() + "/" + self.name
Skia's avatar
Skia committed
1060

Skia's avatar
Skia committed
1061
1062
1063
    def get_display_name(self):
        return self.name

1064
    def get_download_url(self):
Sli's avatar
Sli committed
1065
        return reverse("core:download", kwargs={"file_id": self.id})
1066

Skia's avatar
Skia committed
1067
1068
1069
    def __str__(self):
        return self.get_parent_path() + "/" + self.name

Krophil's avatar
Krophil committed
1070

Skia's avatar
Skia committed
1071
1072
class LockError(Exception):
    """There was a lock error on the object"""
Sli's avatar
Sli committed
1073

Skia's avatar
Skia committed
1074
1075
    pass

Krophil's avatar
Krophil committed
1076

Skia's avatar
Skia committed
1077
1078
class AlreadyLocked(LockError):
    """The object is already locked"""
Sli's avatar
Sli committed
1079

Skia's avatar
Skia committed
1080
1081
    pass

Krophil's avatar
Krophil committed
1082

Skia's avatar
Skia committed
1083
1084
class NotLocked(LockError):
    """The object is not locked"""
Sli's avatar
Sli committed
1085

Skia's avatar
Skia committed
1086
1087
    pass

Krophil's avatar
Krophil committed
1088

Skia's avatar
Skia committed
1089
class Page(models.Model):
Skia's avatar
Skia committed
1090
1091
1092
1093
1094
1095
1096
    """
    The page class to build a Wiki
    Each page may have a parent and it's URL is of the form my.site/page/<grd_pa>/<parent>/<mypage>
    It has an ID field, but don't use it, since it's only there for DB part, and because compound primary key is
    awkward!
    Prefere querying pages with Page.get_page_by_full_name()