models.py 46.9 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.urls 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
402

403
    @cached_property
Sli's avatar
Sli committed
404
    def is_launderette_manager(self):
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
        """
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
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):
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
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):
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
class AnonymousUser(AuthAnonymousUser):
tleb's avatar
tleb committed
662
    def __init__(self):
663 664
        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 747 748
    user = models.OneToOneField(
        User, related_name="_preferences", on_delete=models.CASCADE
    )
Skia's avatar
Skia committed
749
    receive_weekmail = models.BooleanField(
Sli's avatar
Sli committed
750
        _("do you want to receive the weekmail"), default=False
751
    )
Sli's avatar
Sli committed
752
    show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
753
    notify_on_click = models.BooleanField(
Sli's avatar
Sli committed
754
        _("get a notification for every click"), default=False
755 756
    )
    notify_on_refill = models.BooleanField(
Sli's avatar
Sli committed
757
        _("get a notification for every refilling"), default=False
758 759
    )

Skia's avatar
Skia committed
760 761 762 763 764 765
    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
766

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

Krophil's avatar
Krophil committed
770

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

Krophil's avatar
Krophil committed
774

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

Krophil's avatar
Krophil committed
778

Skia's avatar
Skia committed
779
class SithFile(models.Model):
Sli's avatar
Sli committed
780 781
    name = models.CharField(_("file name"), max_length=256, blank=False)
    parent = models.ForeignKey(
782 783 784 785 786 787
        "self",
        related_name="children",
        verbose_name=_("parent"),
        null=True,
        blank=True,
        on_delete=models.CASCADE,
Sli's avatar
Sli committed
788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809
    )
    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,
    )
810 811 812 813 814 815
    owner = models.ForeignKey(
        User,
        related_name="owned_files",
        verbose_name=_("owner"),
        on_delete=models.CASCADE,
    )
Sli's avatar
Sli committed
816 817 818 819 820 821
    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
    )
822
    is_folder = models.BooleanField(_("is folder"), default=True, db_index=True)
Sli's avatar
Sli committed
823
    mime_type = models.CharField(_("mime type"), max_length=30)
Skia's avatar
Skia committed
824
    size = models.IntegerField(_("size"), default=0)
Sli's avatar
Sli committed
825
    date = models.DateTimeField(_("date"), default=timezone.now)
Skia's avatar
Skia committed
826
    is_moderated = models.BooleanField(_("is moderated"), default=False)
Sli's avatar
Sli committed
827 828 829 830 831 832
    moderator = models.ForeignKey(
        User,
        related_name="moderated_files",
        verbose_name=_("owner"),
        null=True,
        blank=True,
833
        on_delete=models.CASCADE,
Sli's avatar
Sli committed
834
    )
835
    asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
Sli's avatar
Sli committed
836
    is_in_sas = models.BooleanField(
837
        _("is in the SAS"), default=False, db_index=True
Sli's avatar
Sli committed
838
    )  # Allows to query this flag, updated at each call to save()
Skia's avatar
Skia committed
839 840 841 842 843

    class Meta:
        verbose_name = _("file")

    def is_owned_by(self, user):
Sli's avatar
Sli committed
844 845 846
        if hasattr(self, "profile_of") and user.is_in_group(
            settings.SITH_MAIN_BOARD_GROUP
        ):
Skia's avatar
Skia committed
847
            return True
Skia's avatar
Skia committed
848
        if user.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID):
Skia's avatar
Skia committed
849
            return True
Skia's avatar
Skia committed
850 851
        if self.is_in_sas and user.is_in_group(settings.SITH_GROUP_SAS_ADMIN_ID):
            return True
Skia's avatar
Skia committed
852 853
        return user.id == self.owner.id

854
    def can_be_viewed_by(self, user):
Sli's avatar
Sli committed
855
        if hasattr(self, "profile_of"):
856
            return user.can_view(self.profile_of)
Sli's avatar
Sli committed
857
        if hasattr(self, "avatar_of"):
858
            return user.can_view(self.avatar_of)
Sli's avatar
Sli committed
859
        if hasattr(self, "scrub_of"):
860 861 862
            return user.can_view(self.scrub_of)
        return False

Skia's avatar
Skia committed
863 864 865 866
    def delete(self):
        for c in self.children.all():
            c.delete()
        self.file.delete()
Skia's avatar
Skia committed
867 868 869 870
        if self.compressed:
            self.compressed.delete()
        if self.thumbnail:
            self.thumbnail.delete()
Skia's avatar
Skia committed
871 872 873 874 875 876 877
        return super(SithFile, self).delete()

    def clean(self):
        """
        Cleans up the file
        """
        super(SithFile, self).clean()
Sli's avatar
Sli committed
878
        if "/" in self.name:
Skia's avatar
Skia committed
879 880
            raise ValidationError(_("Character '/' not authorized in name"))
        if self == self.parent:
Sli's avatar
Sli committed
881 882 883 884 885
            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
886 887
        if self.parent and self.parent.is_file:
            raise ValidationError(
Sli's avatar
Sli committed
888
                _("You can not make a file be a children of a non folder file")
Skia's avatar
Skia committed
889
            )
Sli's avatar
Sli committed
890 891 892 893 894 895 896 897 898 899
        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
900
        if self.is_folder:
Skia's avatar
Skia committed
901 902 903
            if self.file:
                try:
                    import imghdr
Sli's avatar
Sli committed
904 905 906 907 908 909

                    if imghdr.what(None, self.file.read()) not in [
                        "gif",
                        "png",
                        "jpeg",
                    ]:
Skia's avatar
Skia committed
910 911 912 913
                        self.file.delete()
                        self.file = None
                except:
                    self.file = None
Skia's avatar
Skia committed
914 915 916 917 918
            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):
919 920
        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
921 922 923 924 925 926
        copy_rights = False
        if self.id is None:
            copy_rights = True
        super(SithFile, self).save(*args, **kwargs)
        if copy_rights:
            self.copy_rights()
927
        if self.is_in_sas:
Sli's avatar
Sli committed
928 929 930 931 932 933 934 935 936 937 938
            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
939

940 941 942 943 944 945 946 947
    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
948 949 950
    def copy_rights(self):
        """Copy, if possible, the rights of the parent folder"""
        if self.parent is not None:
951 952
            self.edit_groups.set(self.parent.edit_groups.all())
            self.view_groups.set(self.parent.view_groups.all())
Skia's avatar
Skia committed
953 954
            self.save()

Skia's avatar
Skia committed
955
    def move_to(self, parent):
956 957 958 959 960 961 962
        """
        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
963 964
        if not parent.is_folder:
            return
965 966 967 968 969 970 971 972 973 974 975 976 977
        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
978
        elif not self._check_path_consistence():
979 980 981 982 983 984 985
            # 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
986
            old_path = self.file.name  # Should be relative: "./users/skia/bleh.jpg"
987 988 989
            new_path = "." + self.get_full_path()
            print("Old path: %s " % old_path)
            print("New path: %s " % new_path)
990 991 992 993 994 995 996 997 998
            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
999 1000 1001 1002
                        os.rename(
                            settings.MEDIA_ROOT + old_path,
                            settings.MEDIA_ROOT + new_path,
                        )
1003 1004 1005 1006 1007 1008
                        # 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
1009
            print("-" * 80)
1010 1011 1012

    def _check_path_consistence(self):
        file_path = str(self.file)
1013
        file_full_path = settings.MEDIA_ROOT + file_path
1014
        db_path = ".%s" % self.get_full_path()
1015 1016
        if not os.path.exists(file_full_path):
            print("%s: WARNING: real file does not exists!" % self.id)
Sli's avatar
Sli committed
1017
            print("file path: %s" % file_path, end="")
1018 1019
            print("  db path: %s" % db_path)
            return False
1020
        if file_path != db_path:
Sli's avatar
Sli committed
1021 1022
            print("%s: " % self.id, end="")
            print("file path: %s" % file_path, end="")
1023 1024
            print("  db path: %s" % db_path)
            return False
1025 1026
        print("%s OK (%s)" % (self.id, file_path))
        return True
1027 1028 1029 1030 1031 1032 1033 1034

    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
1035

Skia's avatar
Skia committed
1036 1037 1038 1039
    def __getattribute__(self, attr):
        if attr == "is_file":
            return not self.is_folder
        else:
Skia's avatar
Skia committed
1040
            return super(SithFile, self).__getattribute__(attr)
Skia's avatar
Skia committed
1041

1042
    @cached_property
Skia's avatar
Skia committed
1043 1044
    def as_picture(self):
        from sas.models import Picture
Sli's avatar
Sli committed
1045

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

1048
    @cached_property
1049 1050
    def as_album(self):
        from sas.models import Album
Sli's avatar
Sli committed
1051

1052 1053
        return Album.objects.filter(id=self.id).first()

Skia's avatar
Skia committed
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068
    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
1069
        return "/" + "/".join([p.name for p in self.get_parent_list()[::-1]])
Skia's avatar
Skia committed
1070

Skia's avatar
Skia committed
1071
    def get_full_path(self):
Sli's avatar
Sli committed
1072
        return self.get_parent_path() + "/" + self.name
Skia's avatar
Skia committed
1073

Skia's avatar
Skia committed
1074 1075 1076
    def get_display_name(self):
        return self.name

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

Skia's avatar
Skia committed
1080 1081 1082
    def __str__(self):
        return self.get_parent_path() + "/" + self.name

Krophil's avatar
Krophil committed
1083

Skia's avatar
Skia committed
1084 1085
class LockError(Exception):
    """There was a lock error on the object"""
Sli's avatar
Sli committed
1086

Skia's avatar
Skia committed
1087 1088
    pass

Krophil's avatar
Krophil committed
1089

Skia's avatar
Skia committed
1090 1091
class AlreadyLocked(LockError):
    """The object is already locked"""
Sli's avatar
Sli committed
1092

Skia's avatar
Skia committed
1093 1094
    pass

Krophil's avatar
Krophil committed
1095

Skia's avatar
Skia committed
1096 1097
class NotLocked(LockError):
    """The object is not locked"""
Sli's avatar
Sli committed
1098

Skia's avatar
Skia committed
1099 1100
    pass

Krophil's avatar
Krophil committed
1101

Skia's avatar
Skia committed
1102
class Page(models.Model):
Skia's avatar
Skia committed
1103 1104 1105 1106 1107 1108 1109
    """
    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()

1110
    Be careful with the _full_name attribute: this field may not be valid until you call save(). It's made for fast
Skia's avatar
Skia committed
1111 1112
    query, but don't rely on it when playing with a Page object, use get_full_name() instead!
    """
Sli's avatar
Sli committed
1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136

    name = models.CharField(
        _("page unix name"),
        max_length=30,
        validators=[
            validators.RegexValidator(
                r"^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$",
                _(
                    "Enter a valid page name. This value may contain only "
                    "unaccented letters, numbers "
                    "and ./+/-/_ characters."
                ),
            )
        ],
        blank=False,
    )
    parent = models.ForeignKey(
        "self",
        related_name="children",
        verbose_name=_("parent"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
Skia's avatar
Skia committed
1137 1138
    # Attention: this field may not be valid until you call save(). It's made for fast query, but don't rely on it when
    # playing with a Page object, use get_full_name() instead!
Sli's avatar
Sli committed
1139
    _full_name = models.CharField(_("page name"), max_length=255, blank=True)
1140
    # This function prevents generating migration upon settings change
Sli's avatar
Sli committed
1141 1142 1143 1144 1145 1146 1147 1148
    def get_default_owner_group():
        return settings.SITH_GROUP_ROOT_ID

    owner_group = models.ForeignKey(
        Group,
        related_name="owned_page",
        verbose_name=_("owner group"),
        default=get_default_owner_group,
1149
        on_delete=models.CASCADE,
Sli's avatar
Sli committed
1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163
    )
    edit_groups = models.ManyToManyField(
        Group, related_name="editable_page", verbose_name=_("edit group"), blank=True
    )
    view_groups = models.ManyToManyField(
        Group, related_name="viewable_page", verbose_name=_("view group"), blank=True
    )
    lock_user = models.ForeignKey(
        User,
        related_name="locked_pages",
        verbose_name=_("lock user"),
        blank=True,
        null=True,
        default=None,
1164
        on_delete=models.CASCADE,
Sli's avatar
Sli committed
1165 1166 1167 1168
    )
    lock_timeout = models.DateTimeField(
        _("lock_timeout"), null=True, blank=True, default=None
    )
Skia's avatar
Skia committed
1169

1170
    class Meta:
Sli's avatar
Sli committed
1171
        unique_together = ("name", "parent")
1172
        permissions = (
1173
            ("change_prop_page", "Can change the page's properties (groups, ...)"),
1174 1175
        )

1176 1177
    @staticmethod
    def get_page_by_full_name(name):
Skia's avatar
Skia committed
1178 1179 1180
        """
        Quicker to get a page with that method rather than building the request every time
        """
1181
        return Page.objects.filter(_full_name=name).first()
1182 1183 1184 1185 1186 1187

    def __init__(self, *args, **kwargs):
        super(Page, self).__init__(*args, **kwargs)

    def clean(self):
        """
Skia's avatar
Skia committed
1188