use google convention for docstrings

This commit is contained in:
thomas girod
2024-07-12 09:34:16 +02:00
parent 07b625d4aa
commit 8c69a94488
72 changed files with 970 additions and 1694 deletions

View File

@ -19,9 +19,7 @@ class TwoDigitMonthConverter:
class BooleanStringConverter:
"""
Converter whose regex match either True or False
"""
"""Converter whose regex match either True or False."""
regex = r"(True)|(False)"

View File

@ -90,12 +90,15 @@ def list_tags(s):
yield parts[1][len(tag_prefix) :]
def parse_semver(s):
"""
Turns a semver string into a 3-tuple or None if the parsing failed, it is a
prerelease or it has build metadata.
def parse_semver(s) -> tuple[int, int, int] | None:
"""Parse a semver string.
See https://semver.org
Returns:
A tuple, if the parsing was successful, else None.
In the latter case, it must probably be a prerelease
or include build metadata.
"""
m = semver_regex.match(s)
@ -106,7 +109,7 @@ def parse_semver(s):
):
return None
return (int(m.group("major")), int(m.group("minor")), int(m.group("patch")))
return int(m.group("major")), int(m.group("minor")), int(m.group("patch"))
def semver_to_s(t):

View File

@ -29,9 +29,7 @@ from django.core.management.commands import compilemessages
class Command(compilemessages.Command):
"""
Wrap call to compilemessages to avoid building whole env
"""
"""Wrap call to compilemessages to avoid building whole env."""
help = """
The usage is the same as the real compilemessages

View File

@ -30,9 +30,7 @@ from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""
Compiles scss in static folder for production
"""
"""Compiles scss in static folder for production."""
help = "Compile scss files from static folder"

View File

@ -53,13 +53,14 @@ _threadlocal = threading.local()
def get_signal_request():
"""
!!! Do not use if your operation is asynchronus !!!
Allow to access current request in signals
This is a hack that looks into the thread
Mainly used for log purpose
"""
"""Allow to access current request in signals.
This is a hack that looks into the thread
Mainly used for log purpose.
!!!danger
Do not use if your operation is asynchronous.
"""
return getattr(_threadlocal, "request", None)

View File

@ -21,11 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
import importlib
import os
import unicodedata
from datetime import date, timedelta
from typing import List, Optional, Union
from typing import TYPE_CHECKING, Optional
from django.conf import settings
from django.contrib.auth.models import (
@ -56,6 +58,9 @@ from phonenumber_field.modelfields import PhoneNumberField
from core import utils
if TYPE_CHECKING:
from club.models import Club
class RealGroupManager(AuthGroupManager):
def get_queryset(self):
@ -68,8 +73,7 @@ class MetaGroupManager(AuthGroupManager):
class Group(AuthGroup):
"""
Implement both RealGroups and Meta groups
"""Implement both RealGroups and Meta groups.
Groups are sorted by their is_meta property
"""
@ -87,9 +91,6 @@ class Group(AuthGroup):
ordering = ["name"]
def get_absolute_url(self):
"""
This is needed for black magic powered UpdateView's children
"""
return reverse("core:group_list")
def save(self, *args, **kwargs):
@ -104,8 +105,8 @@ class Group(AuthGroup):
class MetaGroup(Group):
"""
MetaGroups are dynamically created groups.
"""MetaGroups are dynamically created groups.
Generally used with clubs where creating a club creates two groups:
* club-SITH_BOARD_SUFFIX
@ -123,14 +124,14 @@ class MetaGroup(Group):
self.is_meta = True
@cached_property
def associated_club(self):
"""
Return the group associated with this meta group
def associated_club(self) -> Club | None:
"""Return the group associated with this meta group.
The result of this function is cached
:return: The associated club if it exists, else None
:rtype: club.models.Club | None
Returns:
The associated club if it exists, else None
"""
from club.models import Club
@ -150,8 +151,8 @@ class MetaGroup(Group):
class RealGroup(Group):
"""
RealGroups are created by the developer.
"""RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"""
@ -173,22 +174,26 @@ def validate_promo(value):
def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
"""
Search for a group by its primary key or its name.
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
:param pk: The primary key of the group
:param name: The name of the group
:return: The group if it exists, else None
:raise ValueError: If no group matches the criteria
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: Union[str, int] = pk if pk is not None else name.replace(" ", "_")
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
@ -211,8 +216,7 @@ def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
class User(AbstractBaseUser):
"""
Defines the base user class, useable in every app
"""Defines the base user class, useable in every app.
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
@ -382,9 +386,6 @@ class User(AbstractBaseUser):
return self.is_active and self.is_superuser
def get_absolute_url(self):
"""
This is needed for black magic powered UpdateView's children
"""
return reverse("core:user_profile", kwargs={"user_id": self.pk})
def __str__(self):
@ -412,8 +413,7 @@ class User(AbstractBaseUser):
return 0
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
"""
Check if this user is in the given group.
"""Check if this user is in the given group.
Either a group id or a group name must be provided.
If both are passed, only the id will be considered.
@ -421,7 +421,8 @@ class User(AbstractBaseUser):
If no group is found, return False.
If a group is found, check if this user is in the latter.
:return: True if the user is the group, else False
Returns:
True if the user is the group, else False
"""
if pk is not None:
group: Optional[Group] = get_group(pk=pk)
@ -454,11 +455,12 @@ class User(AbstractBaseUser):
return group in self.cached_groups
@property
def cached_groups(self) -> List[Group]:
"""
Get the list of groups this user is in.
def cached_groups(self) -> list[Group]:
"""Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
:return: A list of all the groups this user is in
Returns: A list of all the groups this user is in.
"""
groups = cache.get(f"user_{self.id}_groups")
if groups is None:
@ -523,9 +525,8 @@ class User(AbstractBaseUser):
@cached_property
def age(self) -> int:
"""
Return the age this user has the day the method is called.
If the user has not filled his age, return 0
"""Return the age this user has the day the method is called.
If the user has not filled his age, return 0.
"""
if self.date_of_birth is None:
return 0
@ -576,31 +577,27 @@ class User(AbstractBaseUser):
}
def get_full_name(self):
"""
Returns the first_name plus the last_name, with a space in between.
"""
"""Returns the first_name plus the last_name, with a space in between."""
full_name = "%s %s" % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self):
"Returns the short name for the user."
"""Returns the short name for the user."""
if self.nick_name:
return self.nick_name
return self.first_name + " " + self.last_name
def get_display_name(self):
"""
Returns the display name of the user.
A nickname if possible, otherwise, the full name
def get_display_name(self) -> str:
"""Returns the display name of the user.
A nickname if possible, otherwise, the full name.
"""
if self.nick_name:
return "%s (%s)" % (self.get_full_name(), self.nick_name)
return self.get_full_name()
def get_age(self):
"""
Returns the age
"""
"""Returns the age."""
today = timezone.now()
born = self.date_of_birth
return (
@ -608,18 +605,18 @@ class User(AbstractBaseUser):
)
def email_user(self, subject, message, from_email=None, **kwargs):
"""
Sends an email to this User.
"""
"""Sends an email to this User."""
if from_email is None:
from_email = settings.DEFAULT_FROM_EMAIL
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
def generate_username(self) -> str:
"""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.
"""
def remove_accents(data):
@ -644,9 +641,7 @@ class User(AbstractBaseUser):
return user_name
def is_owner(self, obj):
"""
Determine if the object is owned by the user
"""
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
@ -656,9 +651,7 @@ class User(AbstractBaseUser):
return False
def can_edit(self, obj):
"""
Determine if the object can be edited by the user
"""
"""Determine if the object can be edited by the user."""
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
@ -672,9 +665,7 @@ class User(AbstractBaseUser):
return False
def can_view(self, obj):
"""
Determine if the object can be viewed by the user
"""
"""Determine if the object can be viewed by the user."""
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
@ -730,11 +721,8 @@ class User(AbstractBaseUser):
return infos
@cached_property
def clubs_with_rights(self):
"""
:return: the list of clubs where the user has rights
:rtype: list[club.models.Club]
"""
def clubs_with_rights(self) -> list[Club]:
"""The list of clubs where the user has rights"""
memberships = self.memberships.ongoing().board().select_related("club")
return [m.club for m in memberships]
@ -796,9 +784,7 @@ class AnonymousUser(AuthAnonymousUser):
raise PermissionDenied
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
"""
The anonymous user is only in the public group
"""
"""The anonymous user is only in the public group."""
allowed_id = settings.SITH_GROUP_PUBLIC_ID
if pk is not None:
return pk == allowed_id
@ -957,16 +943,15 @@ class SithFile(models.Model):
).save()
def can_be_managed_by(self, user: User) -> bool:
"""
Tell if the user can manage the file (edit, delete, etc.) or not.
"""Tell if the user can manage the file (edit, delete, etc.) or not.
Apply the following rules:
- If the file is not in the SAS nor in the profiles directory, it can be "managed" by anyone -> return True
- If the file is in the SAS, only the SAS admins (or roots) can manage it -> return True if the user is in the SAS admin group or is a root
- If the file is in the profiles directory, only the roots can manage it -> return True if the user is a root
- If the file is in the profiles directory, only the roots can manage it -> return True if the user is a root.
:returns: True if the file is managed by the SAS or within the profiles directory, False otherwise
Returns:
True if the file is managed by the SAS or within the profiles directory, False otherwise
"""
# If the file is not in the SAS nor in the profiles directory, it can be "managed" by anyone
profiles_dir = SithFile.objects.filter(name="profiles").first()
if not self.is_in_sas and not profiles_dir in self.get_parent_list():
@ -1017,9 +1002,7 @@ class SithFile(models.Model):
return super().delete()
def clean(self):
"""
Cleans up the file
"""
"""Cleans up the file."""
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
@ -1070,15 +1053,14 @@ class SithFile(models.Model):
c.apply_rights_recursively(only_folders=only_folders)
def copy_rights(self):
"""Copy, if possible, the rights of the parent folder"""
"""Copy, if possible, the rights of the parent folder."""
if self.parent is not None:
self.edit_groups.set(self.parent.edit_groups.all())
self.view_groups.set(self.parent.view_groups.all())
self.save()
def move_to(self, parent):
"""
Move a file to a new parent.
"""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
@ -1091,10 +1073,7 @@ class SithFile(models.Model):
self.save()
def _repair_fs(self):
"""
This function rebuilds recursively the filesystem as it should be
regarding the DB tree.
"""
"""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()
@ -1197,19 +1176,19 @@ class SithFile(models.Model):
class LockError(Exception):
"""There was a lock error on the object"""
"""There was a lock error on the object."""
pass
class AlreadyLocked(LockError):
"""The object is already locked"""
"""The object is already locked."""
pass
class NotLocked(LockError):
"""The object is not locked"""
"""The object is not locked."""
pass
@ -1220,12 +1199,11 @@ def get_default_owner_group():
class Page(models.Model):
"""
The page class to build a Wiki
"""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()
Prefere querying pages with Page.get_page_by_full_name().
Be careful with the _full_name attribute: 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!
@ -1294,9 +1272,7 @@ class Page(models.Model):
return self.get_full_name()
def save(self, *args, **kwargs):
"""
Performs some needed actions before and after saving a page in database
"""
"""Performs some needed actions before and after saving a page in database."""
locked = kwargs.pop("force_lock", False)
if not locked:
locked = self.is_locked()
@ -1317,22 +1293,15 @@ class Page(models.Model):
self.unset_lock()
def get_absolute_url(self):
"""
This is needed for black magic powered UpdateView's children
"""
return reverse("core:page", kwargs={"page_name": self._full_name})
@staticmethod
def get_page_by_full_name(name):
"""
Quicker to get a page with that method rather than building the request every time
"""
"""Quicker to get a page with that method rather than building the request every time."""
return Page.objects.filter(_full_name=name).first()
def clean(self):
"""
Cleans up only the name for the moment, but this can be used to make any treatment before saving the object
"""
"""Cleans up only the name for the moment, but this can be used to make any treatment before saving the object."""
if "/" in self.name:
self.name = self.name.split("/")[-1]
if (
@ -1367,10 +1336,11 @@ class Page(models.Model):
return l
def is_locked(self):
"""
Is True if the page is locked, False otherwise
This is where the timeout is handled, so a locked page for which the timeout is reach will be unlocked and this
function will return False
"""Is True if the page is locked, False otherwise.
This is where the timeout is handled,
so a locked page for which the timeout is reach will be unlocked and this
function will return False.
"""
if self.lock_timeout and (
timezone.now() - self.lock_timeout > timedelta(minutes=5)
@ -1384,9 +1354,7 @@ class Page(models.Model):
)
def set_lock(self, user):
"""
Sets a lock on the current page or raise an AlreadyLocked exception
"""
"""Sets a lock on the current page or raise an AlreadyLocked exception."""
if self.is_locked() and self.get_lock() != user:
raise AlreadyLocked("The page is already locked by someone else")
self.lock_user = user
@ -1395,41 +1363,34 @@ class Page(models.Model):
# print("Locking page")
def set_lock_recursive(self, user):
"""
Locks recursively all the child pages for editing properties
"""
"""Locks recursively all the child pages for editing properties."""
for p in self.children.all():
p.set_lock_recursive(user)
self.set_lock(user)
def unset_lock_recursive(self):
"""
Unlocks recursively all the child pages
"""
"""Unlocks recursively all the child pages."""
for p in self.children.all():
p.unset_lock_recursive()
self.unset_lock()
def unset_lock(self):
"""Always try to unlock, even if there is no lock"""
"""Always try to unlock, even if there is no lock."""
self.lock_user = None
self.lock_timeout = None
super().save()
# print("Unlocking page")
def get_lock(self):
"""
Returns the page's mutex containing the time and the user in a dict
"""
"""Returns the page's mutex containing the time and the user in a dict."""
if self.lock_user:
return self.lock_user
raise NotLocked("The page is not locked and thus can not return its user")
def get_full_name(self):
"""
Computes the real full_name of the page based on its name and its parent's name
"""Computes the real full_name of the page based on its name and its parent's name
You can and must rely on this function when working on a page object that is not freshly fetched from the DB
(For example when treating a Page object coming from a form)
(For example when treating a Page object coming from a form).
"""
if self.parent is None:
return self.name
@ -1463,8 +1424,8 @@ class Page(models.Model):
class PageRev(models.Model):
"""
This is the true content of the page.
"""True content of the page.
Each page object has a revisions field that is a list of PageRev, ordered by date.
my_page.revisions.last() gives the PageRev object that is the most up-to-date, and thus,
is the real content of the page.
@ -1492,9 +1453,6 @@ class PageRev(models.Model):
self.page.unset_lock()
def get_absolute_url(self):
"""
This is needed for black magic powered UpdateView's children
"""
return reverse("core:page", kwargs={"page_name": self.page._full_name})
def __getattribute__(self, attr):
@ -1573,9 +1531,7 @@ class Gift(models.Model):
class OperationLog(models.Model):
"""
General purpose log object to register operations
"""
"""General purpose log object to register operations."""
date = models.DateTimeField(_("date"), auto_now_add=True)
label = models.CharField(_("label"), max_length=255)

View File

@ -21,24 +21,26 @@
#
#
"""
This page is useful for custom migration tricks.
Sometimes, when you need to have a migration hack and you think it can be
useful again, put it there, we never know if we might need the hack again.
"""Collection of utils for custom migration tricks.
Sometimes, when you need to have a migration hack,
and you think it can be useful again,
put it there, we never know if we might need the hack again.
"""
from django.db import connection, migrations
class PsqlRunOnly(migrations.RunSQL):
"""
This is an SQL runner that will launch the given command only if
the used DBMS is PostgreSQL.
"""SQL runner for PostgreSQL-only queries.
It may be useful to run Postgres' specific SQL, or to take actions
that would be non-senses with backends other than Postgre, such
as disabling particular constraints that would prevent the migration
to run successfully.
If used on another DBMS than Postgres, it will be a noop.
See `club/migrations/0010_auto_20170912_2028.py` as an example.
Some explanations can be found here too:
https://stackoverflow.com/questions/28429933/django-migrations-using-runpython-to-commit-changes

View File

@ -31,9 +31,7 @@ from django.core.files.storage import FileSystemStorage
class ScssFinder(FileSystemFinder):
"""
Find static *.css files compiled on the fly
"""
"""Find static *.css files compiled on the fly."""
locations = []

View File

@ -35,10 +35,9 @@ from core.scss.storage import ScssFileStorage, find_file
class ScssProcessor(object):
"""
If DEBUG mode enabled : compile the scss file
"""If DEBUG mode enabled : compile the scss file
Else : give the path of the corresponding css supposed to already be compiled
Don't forget to use compilestatics to compile scss for production
Don't forget to use compilestatics to compile scss for production.
"""
prefix = iri_to_uri(getattr(settings, "STATIC_URL", "/static/"))

View File

@ -91,9 +91,9 @@ class IndexSignalProcessor(signals.BaseSignalProcessor):
class BigCharFieldIndex(indexes.CharField):
"""
Workaround to avoid xapian.InvalidArgument: Term too long (> 245)
See https://groups.google.com/forum/#!topic/django-haystack/hRJKcPNPXqw/discussion
"""Workaround to avoid xapian.InvalidArgument: Term too long (> 245).
See https://groups.google.com/forum/#!topic/django-haystack/hRJKcPNPXqw/discussion.
"""
def prepare(self, term):

View File

@ -7,9 +7,7 @@ from core.models import User
@receiver(m2m_changed, sender=User.groups.through, dispatch_uid="user_groups_changed")
def user_groups_changed(sender, instance: User, **kwargs):
"""
Clear the cached groups of the user
"""
"""Clear the cached groups of the user."""
# As a m2m relationship doesn't live within the model
# but rather on an intermediary table, there is no
# model method to override, meaning we must use

View File

@ -30,11 +30,11 @@ from jinja2.parser import Parser
class HoneypotExtension(Extension):
"""
Wrapper around the honeypot extension tag
Known limitation: doesn't support arguments
"""Wrapper around the honeypot extension tag.
Usage: {% render_honeypot_field %}
Known limitation: doesn't support arguments.
Usage: `{% render_honeypot_field %}`
"""
tags = {"render_honeypot_field"}

View File

@ -46,9 +46,7 @@ def markdown(text):
def phonenumber(
value, country="FR", number_format=phonenumbers.PhoneNumberFormat.NATIONAL
):
"""
This filter is kindly borrowed from https://github.com/foundertherapy/django-phonenumber-filter
"""
# collectivised from https://github.com/foundertherapy/django-phonenumber-filter.
value = str(value)
try:
parsed = phonenumbers.parse(value, country)
@ -59,6 +57,12 @@ def phonenumber(
@register.filter(name="truncate_time")
def truncate_time(value, time_unit):
"""Remove everything in the time format lower than the specified unit.
Args:
value: the value to truncate
time_unit: the lowest unit to display
"""
value = str(value)
return {
"millis": lambda: value.split(".")[0],
@ -81,8 +85,6 @@ def format_timedelta(value: datetime.timedelta) -> str:
@register.simple_tag()
def scss(path):
"""
Return path of the corresponding css file after compilation
"""
"""Return path of the corresponding css file after compilation."""
processor = ScssProcessor(path)
return processor.get_converted_scss()

View File

@ -105,7 +105,7 @@ class TestUserRegistration:
def test_register_fail_with_not_existing_email(
self, client: Client, valid_payload, monkeypatch
):
"""Test that, when email is valid but doesn't actually exist, registration fails"""
"""Test that, when email is valid but doesn't actually exist, registration fails."""
def always_fail(*_args, **_kwargs):
raise SMTPException
@ -127,10 +127,7 @@ class TestUserLogin:
return User.objects.first()
def test_login_fail(self, client, user):
"""
Should not login a user correctly
"""
"""Should not login a user correctly."""
response = client.post(
reverse("core:login"),
{
@ -158,9 +155,7 @@ class TestUserLogin:
assert response.wsgi_request.user.is_anonymous
def test_login_success(self, client, user):
"""
Should login a user correctly
"""
"""Should login a user correctly."""
response = client.post(
reverse("core:login"),
{
@ -210,7 +205,7 @@ class TestUserLogin:
],
)
def test_custom_markdown_syntax(md, html):
"""Test the homemade markdown syntax"""
"""Test the homemade markdown syntax."""
assert markdown(md) == f"<p>{html}</p>\n"
@ -233,7 +228,6 @@ class PageHandlingTest(TestCase):
def test_create_page_ok(self):
"""Should create a page correctly."""
response = self.client.post(
reverse("core:page_new"),
{"parent": "", "name": "guy", "owner_group": self.root_group.id},
@ -274,9 +268,7 @@ class PageHandlingTest(TestCase):
assert '<a href="/page/guy/bibou/">' in str(response.content)
def test_access_child_page_ok(self):
"""
Should display a page correctly
"""
"""Should display a page correctly."""
parent = Page(name="guy", owner_group=self.root_group)
parent.save(force_lock=True)
page = Page(name="bibou", owner_group=self.root_group, parent=parent)
@ -289,18 +281,14 @@ class PageHandlingTest(TestCase):
self.assertIn('<a href="/page/guy/bibou/edit/">', html)
def test_access_page_not_found(self):
"""
Should not display a page correctly
"""
"""Should not display a page correctly."""
response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"}))
assert response.status_code == 200
html = response.content.decode()
self.assertIn('<a href="/page/create/?page=swagg">', html)
def test_create_page_markdown_safe(self):
"""
Should format the markdown and escape html correctly
"""
"""Should format the markdown and escape html correctly."""
self.client.post(
reverse("core:page_new"), {"parent": "", "name": "guy", "owner_group": "1"}
)
@ -335,13 +323,13 @@ http://git.an
class UserToolsTest:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page"""
"""An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools"))
assert response.status_code == 403
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username):
"""All existing users should be able to see the test page"""
"""All existing users should be able to see the test page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(reverse("core:user_tools"))
@ -391,9 +379,8 @@ class FileHandlingTest(TestCase):
class UserIsInGroupTest(TestCase):
"""
Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended.
"""
@classmethod
@ -450,30 +437,24 @@ class UserIsInGroupTest(TestCase):
assert user.is_in_group(name=meta_groups_members) is False
def test_anonymous_user(self):
"""
Test that anonymous users are only in the public group
"""
"""Test that anonymous users are only in the public group."""
user = AnonymousUser()
self.assert_only_in_public_group(user)
def test_not_subscribed_user(self):
"""
Test that users who never subscribed are only in the public group
"""
"""Test that users who never subscribed are only in the public group."""
self.assert_only_in_public_group(self.toto)
def test_wrong_parameter_fail(self):
"""
Test that when neither the pk nor the name argument is given,
the function raises a ValueError
"""Test that when neither the pk nor the name argument is given,
the function raises a ValueError.
"""
with self.assertRaises(ValueError):
self.toto.is_in_group()
def test_number_queries(self):
"""
Test that the number of db queries is stable
and that less queries are made when making a new call
"""Test that the number of db queries is stable
and that less queries are made when making a new call.
"""
# make sure Skia is in at least one group
self.skia.groups.add(Group.objects.first().pk)
@ -497,9 +478,8 @@ class UserIsInGroupTest(TestCase):
self.skia.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self):
"""
Test that when the membership of a user end,
the cache is properly invalidated
"""Test that when the membership of a user end,
the cache is properly invalidated.
"""
membership = Membership.objects.create(
club=self.club, user=self.toto, end_date=None
@ -515,9 +495,8 @@ class UserIsInGroupTest(TestCase):
assert self.toto.is_in_group(name=meta_groups_members) is False
def test_cache_properly_cleared_group(self):
"""
Test that when a user is removed from a group,
the is_in_group_method return False when calling it again
"""Test that when a user is removed from a group,
the is_in_group_method return False when calling it again.
"""
# testing with pk
self.toto.groups.add(self.com_admin.pk)
@ -534,9 +513,8 @@ class UserIsInGroupTest(TestCase):
assert self.toto.is_in_group(name="SAS admin") is False
def test_not_existing_group(self):
"""
Test that searching for a not existing group
returns False
"""Test that searching for a not existing group
returns False.
"""
assert self.skia.is_in_group(name="This doesn't exist") is False
@ -557,9 +535,7 @@ class DateUtilsTest(TestCase):
cls.spring_first_day = date(2023, cls.spring_month, cls.spring_day)
def test_get_semester(self):
"""
Test that the get_semester function returns the correct semester string
"""
"""Test that the get_semester function returns the correct semester string."""
assert get_semester_code(self.autumn_semester_january) == "A24"
assert get_semester_code(self.autumn_semester_september) == "A24"
assert get_semester_code(self.autumn_first_day) == "A24"
@ -568,9 +544,7 @@ class DateUtilsTest(TestCase):
assert get_semester_code(self.spring_first_day) == "P23"
def test_get_start_of_semester_fixed_date(self):
"""
Test that the get_start_of_semester correctly the starting date of the semester.
"""
"""Test that the get_start_of_semester correctly the starting date of the semester."""
automn_2024 = date(2024, self.autumn_month, self.autumn_day)
assert get_start_of_semester(self.autumn_semester_january) == automn_2024
assert get_start_of_semester(self.autumn_semester_september) == automn_2024
@ -581,9 +555,8 @@ class DateUtilsTest(TestCase):
assert get_start_of_semester(self.spring_first_day) == spring_2023
def test_get_start_of_semester_today(self):
"""
Test that the get_start_of_semester returns the start of the current semester
when no date is given
"""Test that the get_start_of_semester returns the start of the current semester
when no date is given.
"""
with freezegun.freeze_time(self.autumn_semester_september):
assert get_start_of_semester() == self.autumn_first_day
@ -592,8 +565,7 @@ class DateUtilsTest(TestCase):
assert get_start_of_semester() == self.spring_first_day
def test_get_start_of_semester_changing_date(self):
"""
Test that the get_start_of_semester correctly gives the starting date of the semester,
"""Test that the get_start_of_semester correctly gives the starting date of the semester,
even when the semester changes while the server isn't restarted.
"""
spring_2023 = date(2023, self.spring_month, self.spring_day)

View File

@ -31,9 +31,7 @@ from PIL.Image import Resampling
def get_git_revision_short_hash() -> str:
"""
Return the short hash of the current commit
"""
"""Return the short hash of the current commit."""
try:
output = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
if isinstance(output, bytes):
@ -44,8 +42,7 @@ def get_git_revision_short_hash() -> str:
def get_start_of_semester(today: Optional[date] = None) -> date:
"""
Return the date of the start of the semester of the given date.
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
The current semester is computed as follows:
@ -54,8 +51,11 @@ def get_start_of_semester(today: Optional[date] = None) -> date:
- If the date is between 01/01 and 15/02 => Autumn semester of the previous year.
- If the date is between 15/02 and 15/08 => Spring semester
:param today: the date to use to compute the semester. If None, use today's date.
:return: the date of the start of the semester
Args:
today: the date to use to compute the semester. If None, use today's date.
Returns:
the date of the start of the semester
"""
if today is None:
today = timezone.now().date()
@ -72,16 +72,18 @@ def get_start_of_semester(today: Optional[date] = None) -> date:
def get_semester_code(d: Optional[date] = None) -> str:
"""
Return the semester code of the given date.
"""Return the semester code of the given date.
If no date is given, return the semester code of the current semester.
The semester code is an upper letter (A for autumn, P for spring),
followed by the last two digits of the year.
For example, the autumn semester of 2018 is "A18".
:param d: the date to use to compute the semester. If None, use today's date.
:return: the semester code corresponding to the given date
Args:
d: the date to use to compute the semester. If None, use today's date.
Returns:
the semester code corresponding to the given date
"""
if d is None:
d = timezone.now().date()
@ -147,8 +149,15 @@ def exif_auto_rotate(image):
return image
def doku_to_markdown(text):
"""This is a quite correct doku translator"""
def doku_to_markdown(text: str) -> str:
"""Convert doku text to the corresponding markdown.
Args:
text: the doku text to convert
Returns:
The converted markdown text
"""
text = re.sub(
r"([^:]|^)\/\/(.*?)\/\/", r"*\2*", text
) # Italic (prevents protocol:// conflict)
@ -235,7 +244,14 @@ def doku_to_markdown(text):
def bbcode_to_markdown(text):
"""This is a very basic BBcode translator"""
"""Convert bbcode text to the corresponding markdown.
Args:
text: the bbcode text to convert
Returns:
The converted markdown text
"""
text = re.sub(r"\[b\](.*?)\[\/b\]", r"**\1**", text, flags=re.DOTALL) # Bold
text = re.sub(r"\[i\](.*?)\[\/i\]", r"*\1*", text, flags=re.DOTALL) # Italic
text = re.sub(r"\[u\](.*?)\[\/u\]", r"__\1__", text, flags=re.DOTALL) # Underline

View File

@ -23,6 +23,7 @@
#
import types
from typing import Any
from django.core.exceptions import (
ImproperlyConfigured,
@ -39,6 +40,7 @@ from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from sentry_sdk import last_event_id
from core.models import User
from core.views.forms import LoginForm
@ -60,60 +62,63 @@ def internal_servor_error(request):
return HttpResponseServerError(render(request, "core/500.jinja"))
def can_edit_prop(obj, user):
"""
:param obj: Object to test for permission
:param user: core.models.User to test permissions against
:return: if user is authorized to edit object properties
:rtype: bool
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
:Example:
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
.. code-block:: python
Returns:
True if user is authorized to edit object properties else False
Examples:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.is_owner(obj):
return True
return False
def can_edit(obj, user):
"""
:param obj: Object to test for permission
:param user: core.models.User to test permissions against
:return: if user is authorized to edit object
:rtype: bool
def can_edit(obj: Any, user: User):
"""Can the user edit the object.
:Example:
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
.. code-block:: python
Returns:
True if user is authorized to edit object else False
if not can_edit(self.object ,request.user):
Examples:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj, user):
"""
:param obj: Object to test for permission
:param user: core.models.User to test permissions against
:return: if user is authorized to see object
:rtype: bool
def can_view(obj: Any, user: User):
"""Can the user see the object.
:Example:
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
.. code-block:: python
Returns:
True if user is authorized to see object else False
Examples:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
@ -121,20 +126,22 @@ def can_view(obj, user):
class GenericContentPermissionMixinBuilder(View):
"""
Used to build permission mixins
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties
on two properties.
:prop permission_function: function to test permission with, takes an object and an user an return a bool
:prop raised_error: permission to be raised
:raises: raised_error
Attributes:
raised_error: permission to be raised
"""
permission_function = lambda obj, user: False
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
@ -162,11 +169,12 @@ class GenericContentPermissionMixinBuilder(View):
class CanCreateMixin(View):
"""
This view is made to protect any child view that would create an object, and thus, that can not be protected by any
of the following mixin
"""Protect any child view that would create an object.
:raises: PermissionDenied
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def dispatch(self, request, *arg, **kwargs):
@ -183,55 +191,54 @@ class CanCreateMixin(View):
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""
This view is made to protect any child view that would be showing some properties of an object that are restricted
to only the owner group of the given object.
In other word, you can make a view with this view as parent, and it would be retricted to the users that are in the
object's owner_group
"""Ensure the user has owner permissions on the child view object.
:raises: PermissionDenied
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""
This view makes exactly the same thing as its direct parent, but checks the group on the edit_groups field of the
object
"""Ensure the user has permission to edit this view's object.
:raises: PermissionDenied
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""
This view still makes exactly the same thing as its direct parent, but checks the group on the view_groups field of
the object
"""Ensure the user has permission to view this view's object.
:raises: PermissionDenied
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class UserIsRootMixin(GenericContentPermissionMixinBuilder):
"""
This view check if the user is root
"""Allow only root admins.
:raises: PermissionDenied
Raises:
PermissionDenied: if the user isn't root
"""
permission_function = lambda obj, user: user.is_root
class FormerSubscriberMixin(View):
"""
This view check if the user was at least an old subscriber
"""Check if the user was at least an old subscriber.
:raises: PermissionDenied
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
@ -241,10 +248,10 @@ class FormerSubscriberMixin(View):
class UserIsLoggedMixin(View):
"""
This view check if the user is logged
"""Check if the user is logged.
:raises: PermissionDenied
Raises:
PermissionDenied:
"""
def dispatch(self, request, *args, **kwargs):
@ -254,9 +261,7 @@ class UserIsLoggedMixin(View):
class TabedViewMixin(View):
"""
This view provide the basic functions for displaying tabs in the template
"""
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
@ -299,7 +304,7 @@ class QuickNotifMixin:
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context"""
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
@ -312,21 +317,15 @@ class QuickNotifMixin:
class DetailFormView(SingleObjectMixin, FormView):
"""
Class that allow both a detail view and a form view
"""
"""Class that allow both a detail view and a form view."""
def get_object(self):
"""
Get current group from id in url
"""
"""Get current group from id in url."""
return self.cached_object
@cached_property
def cached_object(self):
"""
Optimisation on group retrieval
"""
"""Optimisation on group retrieval."""
return super().get_object()

View File

@ -42,8 +42,7 @@ from counter.models import Counter
def send_file(request, file_id, file_class=SithFile, file_attr="file"):
"""
Send a file through Django without loading the whole file into
"""Send a file through Django without loading the whole file into
memory at once. The FileWrapper will turn the file object into an
iterator for chunks of 8KB.
"""
@ -268,7 +267,7 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
class FileView(CanViewMixin, DetailView, FormMixin):
"""This class handle the upload of new files into a folder"""
"""Handle the upload of new files into a folder."""
model = SithFile
pk_url_kwarg = "file_id"
@ -278,8 +277,8 @@ class FileView(CanViewMixin, DetailView, FormMixin):
@staticmethod
def handle_clipboard(request, obj):
"""
This method handles the clipboard in the view.
"""Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from
below, allowing proper handling in the calling view.
Use this method like this:

View File

@ -196,10 +196,9 @@ class RegisteringForm(UserCreationForm):
class UserProfileForm(forms.ModelForm):
"""
Form handling the user profile, managing the files
"""Form handling the user profile, managing the files
This form is actually pretty bad and was made in the rush before the migration. It should be refactored.
TODO: refactor this form
TODO: refactor this form.
"""
class Meta:

View File

@ -13,9 +13,7 @@
#
#
"""
This module contains views to manage Groups
"""
"""Views to manage Groups."""
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
@ -31,9 +29,7 @@ from core.views import CanCreateMixin, CanEditMixin, DetailFormView
class EditMembersForm(forms.Form):
"""
Add and remove members from a Group
"""
"""Add and remove members from a Group."""
def __init__(self, *args, **kwargs):
self.current_users = kwargs.pop("users", [])
@ -53,9 +49,7 @@ class EditMembersForm(forms.Form):
)
def clean_users_added(self):
"""
Check that the user is not trying to add an user already in the group
"""
"""Check that the user is not trying to add an user already in the group."""
cleaned_data = super().clean()
users_added = cleaned_data.get("users_added", None)
if not users_added:
@ -77,9 +71,7 @@ class EditMembersForm(forms.Form):
class GroupListView(CanEditMixin, ListView):
"""
Displays the Group list
"""
"""Displays the Group list."""
model = RealGroup
ordering = ["name"]
@ -87,9 +79,7 @@ class GroupListView(CanEditMixin, ListView):
class GroupEditView(CanEditMixin, UpdateView):
"""
Edit infos of a Group
"""
"""Edit infos of a Group."""
model = RealGroup
pk_url_kwarg = "group_id"
@ -98,9 +88,7 @@ class GroupEditView(CanEditMixin, UpdateView):
class GroupCreateView(CanCreateMixin, CreateView):
"""
Add a new Group
"""
"""Add a new Group."""
model = RealGroup
template_name = "core/create.jinja"
@ -108,9 +96,8 @@ class GroupCreateView(CanCreateMixin, CreateView):
class GroupTemplateView(CanEditMixin, DetailFormView):
"""
Display all users in a given Group
Allow adding and removing users from it
"""Display all users in a given Group
Allow adding and removing users from it.
"""
model = RealGroup
@ -143,9 +130,7 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
class GroupDeleteView(CanEditMixin, DeleteView):
"""
Delete a Group
"""
"""Delete a Group."""
model = RealGroup
pk_url_kwarg = "group_id"

View File

@ -74,9 +74,7 @@ from trombi.views import UserTrombiForm
@method_decorator(check_honeypot, name="post")
class SithLoginView(views.LoginView):
"""
The login View
"""
"""The login View."""
template_name = "core/login.jinja"
authentication_form = LoginForm
@ -85,33 +83,25 @@ class SithLoginView(views.LoginView):
class SithPasswordChangeView(views.PasswordChangeView):
"""
Allows a user to change its password
"""
"""Allows a user to change its password."""
template_name = "core/password_change.jinja"
success_url = reverse_lazy("core:password_change_done")
class SithPasswordChangeDoneView(views.PasswordChangeDoneView):
"""
Allows a user to change its password
"""
"""Allows a user to change its password."""
template_name = "core/password_change_done.jinja"
def logout(request):
"""
The logout view
"""
"""The logout view."""
return views.logout_then_login(request)
def password_root_change(request, user_id):
"""
Allows a root user to change someone's password
"""
"""Allows a root user to change someone's password."""
if not request.user.is_root:
raise PermissionDenied
user = User.objects.filter(id=user_id).first()
@ -131,9 +121,7 @@ def password_root_change(request, user_id):
@method_decorator(check_honeypot, name="post")
class SithPasswordResetView(views.PasswordResetView):
"""
Allows someone to enter an email address for resetting password
"""
"""Allows someone to enter an email address for resetting password."""
template_name = "core/password_reset.jinja"
email_template_name = "core/password_reset_email.jinja"
@ -141,26 +129,20 @@ class SithPasswordResetView(views.PasswordResetView):
class SithPasswordResetDoneView(views.PasswordResetDoneView):
"""
Confirm that the reset email has been sent
"""
"""Confirm that the reset email has been sent."""
template_name = "core/password_reset_done.jinja"
class SithPasswordResetConfirmView(views.PasswordResetConfirmView):
"""
Provide a reset password form
"""
"""Provide a reset password form."""
template_name = "core/password_reset_confirm.jinja"
success_url = reverse_lazy("core:password_reset_complete")
class SithPasswordResetCompleteView(views.PasswordResetCompleteView):
"""
Confirm the password has successfully been reset
"""
"""Confirm the password has successfully been reset."""
template_name = "core/password_reset_complete.jinja"
@ -302,9 +284,7 @@ class UserTabsMixin(TabedViewMixin):
class UserView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display a user's profile
"""
"""Display a user's profile."""
model = User
pk_url_kwarg = "user_id"
@ -321,9 +301,7 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display a user's pictures
"""
"""Display a user's pictures."""
model = User
pk_url_kwarg = "user_id"
@ -361,9 +339,7 @@ def delete_user_godfather(request, user_id, godfather_id, is_father):
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display a user's godfathers
"""
"""Display a user's godfathers."""
model = User
pk_url_kwarg = "user_id"
@ -394,9 +370,7 @@ class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display a user's family tree
"""
"""Display a user's family tree."""
model = User
pk_url_kwarg = "user_id"
@ -415,9 +389,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
class UserGodfathersTreePictureView(CanViewMixin, DetailView):
"""
Display a user's tree as a picture
"""
"""Display a user's tree as a picture."""
model = User
pk_url_kwarg = "user_id"
@ -489,9 +461,7 @@ class UserGodfathersTreePictureView(CanViewMixin, DetailView):
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display a user's stats
"""
"""Display a user's stats."""
model = User
pk_url_kwarg = "user_id"
@ -591,9 +561,7 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
class UserMiniView(CanViewMixin, DetailView):
"""
Display a user's profile
"""
"""Display a user's profile."""
model = User
pk_url_kwarg = "user_id"
@ -602,18 +570,14 @@ class UserMiniView(CanViewMixin, DetailView):
class UserListView(ListView, CanEditPropMixin):
"""
Displays the user list
"""
"""Displays the user list."""
model = User
template_name = "core/user_list.jinja"
class UserUploadProfilePictView(CanEditMixin, DetailView):
"""
Handle the upload of the profile picture taken with webcam in navigator
"""
"""Handle the upload of the profile picture taken with webcam in navigator."""
model = User
pk_url_kwarg = "user_id"
@ -650,9 +614,7 @@ class UserUploadProfilePictView(CanEditMixin, DetailView):
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
"""
Edit a user's profile
"""
"""Edit a user's profile."""
model = User
pk_url_kwarg = "user_id"
@ -663,9 +625,7 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
board_only = []
def remove_restricted_fields(self, request):
"""
Removes edit_once and board_only fields
"""
"""Removes edit_once and board_only fields."""
for i in self.edit_once:
if getattr(self.form.instance, i) and not (
request.user.is_board_member or request.user.is_root
@ -703,9 +663,7 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
"""
Display the user's club(s)
"""
"""Display the user's club(s)."""
model = User
context_object_name = "profile"
@ -715,9 +673,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
"""
Edit a user's preferences
"""
"""Edit a user's preferences."""
model = User
pk_url_kwarg = "user_id"
@ -752,9 +708,7 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
"""
Edit a user's groups
"""
"""Edit a user's groups."""
model = User
pk_url_kwarg = "user_id"
@ -767,9 +721,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView):
"""
Displays the logged user's tools
"""
"""Displays the logged user's tools."""
template_name = "core/user_tools.jinja"
current_tab = "tools"
@ -786,9 +738,7 @@ class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateV
class UserAccountBase(UserTabsMixin, DetailView):
"""
Base class for UserAccount
"""
"""Base class for UserAccount."""
model = User
pk_url_kwarg = "user_id"
@ -809,9 +759,7 @@ class UserAccountBase(UserTabsMixin, DetailView):
class UserAccountView(UserAccountBase):
"""
Display a user's account
"""
"""Display a user's account."""
template_name = "core/user_account.jinja"
@ -858,9 +806,7 @@ class UserAccountView(UserAccountBase):
class UserAccountDetailView(UserAccountBase, YearMixin, MonthMixin):
"""
Display a user's account for month
"""
"""Display a user's account for month."""
template_name = "core/user_account_detail.jinja"