diff --git a/.env.example b/.env.example index 5c4c0d97..2d47ad1f 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,10 @@ SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2 DATABASE_URL=sqlite:///db.sqlite3 #DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith -CACHE_URL=redis://127.0.0.1:6379/0 +REDIS_PORT=7963 +CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0 + +# Used to select which other services to run alongside +# manage.py, pytest and runserver +PROCFILE_STATIC=Procfile.static +PROCFILE_SERVICE=Procfile.service diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml index 2d2aae89..bb10a2f5 100644 --- a/.github/actions/setup_project/action.yml +++ b/.github/actions/setup_project/action.yml @@ -9,6 +9,11 @@ runs: packages: gettext version: 1.0 # increment to reset cache + - name: Install Redis + uses: shogo82148/actions-setup-redis@v1 + with: + redis-version: "7.x" + - name: Install uv uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa17e14c..a8284d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: env: SECRET_KEY: notTheRealOne DATABASE_URL: sqlite:///db.sqlite3 + CACHE_URL: redis://127.0.0.1:6379/0 jobs: pre-commit: @@ -30,7 +31,7 @@ jobs: strategy: fail-fast: false # don't interrupt the other test processes matrix: - pytest-mark: [slow, not slow] + pytest-mark: [not slow] steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 19b65265..ecda5902 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,14 @@ sith/search_indexes/ .coverage coverage_report/ node_modules/ +.env +*.pid # compiled documentation site/ -.env + +### Redis ### + +# Ignore redis binary dump (dump.rdb) files + +*.rdb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 506d9ebe..8d72985b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.8.3 + rev: v0.9.10 hooks: - id: ruff # just check the code, and print the errors - id: ruff # actually fix the fixable errors, but print nothing diff --git a/Procfile.service b/Procfile.service new file mode 100644 index 00000000..4f9c4808 --- /dev/null +++ b/Procfile.service @@ -0,0 +1 @@ +redis: redis-server --port $REDIS_PORT diff --git a/Procfile.static b/Procfile.static new file mode 100644 index 00000000..857b2f2d --- /dev/null +++ b/Procfile.static @@ -0,0 +1 @@ +bundler: npm run serve \ No newline at end of file diff --git a/accounting/admin.py b/accounting/admin.py deleted file mode 100644 index c3386eb8..00000000 --- a/accounting/admin.py +++ /dev/null @@ -1,36 +0,0 @@ -# -# Copyright 2023 © AE UTBM -# ae@utbm.fr / ae.info@utbm.fr -# -# This file is part of the website of the UTBM Student Association (AE UTBM), -# https://ae.utbm.fr. -# -# You can find the source code of the website at https://github.com/ae-utbm/sith -# -# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) -# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE -# OR WITHIN THE LOCAL FILE "LICENSE" -# -# - -from django.contrib import admin - -from accounting.models import ( - AccountingType, - BankAccount, - ClubAccount, - Company, - GeneralJournal, - Label, - Operation, - SimplifiedAccountingType, -) - -admin.site.register(BankAccount) -admin.site.register(ClubAccount) -admin.site.register(GeneralJournal) -admin.site.register(AccountingType) -admin.site.register(SimplifiedAccountingType) -admin.site.register(Operation) -admin.site.register(Label) -admin.site.register(Company) diff --git a/accounting/api.py b/accounting/api.py deleted file mode 100644 index 5ba6c12d..00000000 --- a/accounting/api.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Annotated - -from annotated_types import MinLen -from ninja_extra import ControllerBase, api_controller, paginate, route -from ninja_extra.pagination import PageNumberPaginationExtra -from ninja_extra.schemas import PaginatedResponseSchema - -from accounting.models import ClubAccount, Company -from accounting.schemas import ClubAccountSchema, CompanySchema -from core.auth.api_permissions import CanAccessLookup - - -@api_controller("/lookup", permissions=[CanAccessLookup]) -class AccountingController(ControllerBase): - @route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema]) - @paginate(PageNumberPaginationExtra, page_size=50) - def search_club_account(self, search: Annotated[str, MinLen(1)]): - return ClubAccount.objects.filter(name__icontains=search).values() - - @route.get("/company", response=PaginatedResponseSchema[CompanySchema]) - @paginate(PageNumberPaginationExtra, page_size=50) - def search_company(self, search: Annotated[str, MinLen(1)]): - return Company.objects.filter(name__icontains=search).values() diff --git a/accounting/migrations/0001_initial.py b/accounting/migrations/0001_initial.py index 2f22da8d..29ce0739 100644 --- a/accounting/migrations/0001_initial.py +++ b/accounting/migrations/0001_initial.py @@ -4,7 +4,7 @@ import django.core.validators import django.db.models.deletion from django.db import migrations, models -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -142,7 +142,7 @@ class Migration(migrations.Migration): ), ( "amount", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, default=0, verbose_name="amount", @@ -151,7 +151,7 @@ class Migration(migrations.Migration): ), ( "effective_amount", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, default=0, verbose_name="effective_amount", @@ -176,7 +176,7 @@ class Migration(migrations.Migration): ("number", models.IntegerField(verbose_name="number")), ( "amount", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="amount" ), ), diff --git a/accounting/migrations/0006_remove_all_models.py b/accounting/migrations/0006_remove_all_models.py new file mode 100644 index 00000000..96add19c --- /dev/null +++ b/accounting/migrations/0006_remove_all_models.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.20 on 2025-03-14 16:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [("accounting", "0005_auto_20170324_0917")] + + operations = [ + migrations.RemoveField(model_name="bankaccount", name="club"), + migrations.RemoveField(model_name="clubaccount", name="bank_account"), + migrations.RemoveField(model_name="clubaccount", name="club"), + migrations.DeleteModel(name="Company"), + migrations.RemoveField(model_name="generaljournal", name="club_account"), + migrations.AlterUniqueTogether(name="label", unique_together=None), + migrations.RemoveField(model_name="label", name="club_account"), + migrations.AlterUniqueTogether(name="operation", unique_together=None), + migrations.RemoveField(model_name="operation", name="accounting_type"), + migrations.RemoveField(model_name="operation", name="invoice"), + migrations.RemoveField(model_name="operation", name="journal"), + migrations.RemoveField(model_name="operation", name="label"), + migrations.RemoveField(model_name="operation", name="linked_operation"), + migrations.RemoveField(model_name="operation", name="simpleaccounting_type"), + migrations.RemoveField( + model_name="simplifiedaccountingtype", name="accounting_type" + ), + migrations.DeleteModel(name="AccountingType"), + migrations.DeleteModel(name="BankAccount"), + migrations.DeleteModel(name="ClubAccount"), + migrations.DeleteModel(name="GeneralJournal"), + migrations.DeleteModel(name="Label"), + migrations.DeleteModel(name="Operation"), + migrations.DeleteModel(name="SimplifiedAccountingType"), + ] diff --git a/accounting/models.py b/accounting/models.py index 9b111f61..f4445e69 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -12,509 +12,3 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # - -from decimal import Decimal - -from django.conf import settings -from django.core import validators -from django.core.exceptions import ValidationError -from django.db import models -from django.template import defaultfilters -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -from phonenumber_field.modelfields import PhoneNumberField - -from club.models import Club -from core.models import SithFile, User - - -class CurrencyField(models.DecimalField): - """Custom database field used for currency.""" - - def __init__(self, *args, **kwargs): - kwargs["max_digits"] = 12 - kwargs["decimal_places"] = 2 - super().__init__(*args, **kwargs) - - def to_python(self, value): - try: - return super().to_python(value).quantize(Decimal("0.01")) - except AttributeError: - return None - - -if settings.TESTING: - from model_bakery import baker - - baker.generators.add( - CurrencyField, - lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2), - ) -else: # pragma: no cover - # baker is only used in tests, so we don't need coverage for this part - pass - - -# Accounting classes - - -class Company(models.Model): - name = models.CharField(_("name"), max_length=60) - street = models.CharField(_("street"), max_length=60, blank=True) - city = models.CharField(_("city"), max_length=60, blank=True) - postcode = models.CharField(_("postcode"), max_length=10, blank=True) - country = models.CharField(_("country"), max_length=32, blank=True) - phone = PhoneNumberField(_("phone"), blank=True) - email = models.EmailField(_("email"), blank=True) - website = models.CharField(_("website"), max_length=64, blank=True) - - class Meta: - verbose_name = _("company") - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("accounting:co_edit", kwargs={"co_id": self.id}) - - def get_display_name(self): - return self.name - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - - def can_be_edited_by(self, user): - """Check if that object can be edited by the given user.""" - return user.memberships.filter( - end_date=None, club__role=settings.SITH_CLUB_ROLES_ID["Treasurer"] - ).exists() - - def can_be_viewed_by(self, user): - """Check if that object can be viewed by the given user.""" - return user.memberships.filter( - end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID["Treasurer"] - ).exists() - - -class BankAccount(models.Model): - name = models.CharField(_("name"), max_length=30) - iban = models.CharField(_("iban"), max_length=255, blank=True) - number = models.CharField(_("account number"), max_length=255, blank=True) - club = models.ForeignKey( - Club, - related_name="bank_accounts", - verbose_name=_("club"), - on_delete=models.CASCADE, - ) - - class Meta: - verbose_name = _("Bank account") - ordering = ["club", "name"] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("accounting:bank_details", kwargs={"b_account_id": self.id}) - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_anonymous: - return False - if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - m = self.club.get_membership_for(user) - return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"] - - -class ClubAccount(models.Model): - name = models.CharField(_("name"), max_length=30) - club = models.ForeignKey( - Club, - related_name="club_account", - verbose_name=_("club"), - on_delete=models.CASCADE, - ) - bank_account = models.ForeignKey( - BankAccount, - related_name="club_accounts", - verbose_name=_("bank account"), - on_delete=models.CASCADE, - ) - - class Meta: - verbose_name = _("Club account") - ordering = ["bank_account", "name"] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("accounting:club_details", kwargs={"c_account_id": self.id}) - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_anonymous: - return False - return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - - def can_be_edited_by(self, user): - """Check if that object can be edited by the given user.""" - m = self.club.get_membership_for(user) - return m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"] - - def can_be_viewed_by(self, user): - """Check if that object can be viewed by the given user.""" - m = self.club.get_membership_for(user) - return m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"] - - def has_open_journal(self): - return self.journals.filter(closed=False).exists() - - def get_open_journal(self): - return self.journals.filter(closed=False).first() - - def get_display_name(self): - return _("%(club_account)s on %(bank_account)s") % { - "club_account": self.name, - "bank_account": self.bank_account, - } - - -class GeneralJournal(models.Model): - """Class storing all the operations for a period of time.""" - - start_date = models.DateField(_("start date")) - end_date = models.DateField(_("end date"), null=True, blank=True, default=None) - name = models.CharField(_("name"), max_length=40) - closed = models.BooleanField(_("is closed"), default=False) - club_account = models.ForeignKey( - ClubAccount, - related_name="journals", - null=False, - verbose_name=_("club account"), - on_delete=models.CASCADE, - ) - amount = CurrencyField(_("amount"), default=0) - effective_amount = CurrencyField(_("effective_amount"), default=0) - - class Meta: - verbose_name = _("General journal") - ordering = ["-start_date"] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("accounting:journal_details", kwargs={"j_id": self.id}) - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_anonymous: - return False - if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - return self.club_account.can_be_edited_by(user) - - def can_be_edited_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - return self.club_account.can_be_edited_by(user) - - def can_be_viewed_by(self, user): - return self.club_account.can_be_viewed_by(user) - - def update_amounts(self): - self.amount = 0 - self.effective_amount = 0 - for o in self.operations.all(): - if o.accounting_type.movement_type == "CREDIT": - if o.done: - self.effective_amount += o.amount - self.amount += o.amount - else: - if o.done: - self.effective_amount -= o.amount - self.amount -= o.amount - self.save() - - -class Operation(models.Model): - """An operation is a line in the journal, a debit or a credit.""" - - number = models.IntegerField(_("number")) - journal = models.ForeignKey( - GeneralJournal, - related_name="operations", - null=False, - verbose_name=_("journal"), - on_delete=models.CASCADE, - ) - amount = CurrencyField(_("amount")) - date = models.DateField(_("date")) - remark = models.CharField(_("comment"), max_length=128, null=True, blank=True) - mode = models.CharField( - _("payment method"), - max_length=255, - choices=settings.SITH_ACCOUNTING_PAYMENT_METHOD, - ) - cheque_number = models.CharField( - _("cheque number"), max_length=32, default="", null=True, blank=True - ) - invoice = models.ForeignKey( - SithFile, - related_name="operations", - verbose_name=_("invoice"), - null=True, - blank=True, - on_delete=models.CASCADE, - ) - done = models.BooleanField(_("is done"), default=False) - simpleaccounting_type = models.ForeignKey( - "SimplifiedAccountingType", - related_name="operations", - verbose_name=_("simple type"), - null=True, - blank=True, - on_delete=models.CASCADE, - ) - accounting_type = models.ForeignKey( - "AccountingType", - related_name="operations", - verbose_name=_("accounting type"), - null=True, - blank=True, - on_delete=models.CASCADE, - ) - label = models.ForeignKey( - "Label", - related_name="operations", - verbose_name=_("label"), - null=True, - blank=True, - on_delete=models.SET_NULL, - ) - target_type = models.CharField( - _("target type"), - max_length=10, - choices=[ - ("USER", _("User")), - ("CLUB", _("Club")), - ("ACCOUNT", _("Account")), - ("COMPANY", _("Company")), - ("OTHER", _("Other")), - ], - ) - target_id = models.IntegerField(_("target id"), null=True, blank=True) - target_label = models.CharField( - _("target label"), max_length=32, default="", blank=True - ) - linked_operation = models.OneToOneField( - "self", - related_name="operation_linked_to", - verbose_name=_("linked operation"), - null=True, - blank=True, - default=None, - on_delete=models.CASCADE, - ) - - class Meta: - unique_together = ("number", "journal") - ordering = ["-number"] - - def __str__(self): - return f"{self.amount} € | {self.date} | {self.accounting_type} | {self.done}" - - def save(self, *args, **kwargs): - if self.number is None: - self.number = self.journal.operations.count() + 1 - super().save(*args, **kwargs) - self.journal.update_amounts() - - def get_absolute_url(self): - return reverse("accounting:journal_details", kwargs={"j_id": self.journal.id}) - - def __getattribute__(self, attr): - if attr == "target": - return self.get_target() - else: - return object.__getattribute__(self, attr) - - def clean(self): - super().clean() - if self.date is None: - raise ValidationError(_("The date must be set.")) - elif self.date < self.journal.start_date: - raise ValidationError( - _( - """The date can not be before the start date of the journal, which is -%(start_date)s.""" - ) - % { - "start_date": defaultfilters.date( - self.journal.start_date, settings.DATE_FORMAT - ) - } - ) - if self.target_type != "OTHER" and self.get_target() is None: - raise ValidationError(_("Target does not exists")) - if self.target_type == "OTHER" and self.target_label == "": - raise ValidationError( - _("Please add a target label if you set no existing target") - ) - if not self.accounting_type and not self.simpleaccounting_type: - raise ValidationError( - _( - "You need to provide ether a simplified accounting type or a standard accounting type" - ) - ) - if self.simpleaccounting_type: - self.accounting_type = self.simpleaccounting_type.accounting_type - - @property - def target(self): - return self.get_target() - - def get_target(self): - tar = None - if self.target_type == "USER": - tar = User.objects.filter(id=self.target_id).first() - elif self.target_type == "CLUB": - tar = Club.objects.filter(id=self.target_id).first() - elif self.target_type == "ACCOUNT": - tar = ClubAccount.objects.filter(id=self.target_id).first() - elif self.target_type == "COMPANY": - tar = Company.objects.filter(id=self.target_id).first() - return tar - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_anonymous: - return False - if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - if self.journal.closed: - return False - m = self.journal.club_account.club.get_membership_for(user) - return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"] - - def can_be_edited_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - if self.journal.closed: - return False - m = self.journal.club_account.club.get_membership_for(user) - return m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"] - - -class AccountingType(models.Model): - """Accounting types. - - Those are numbers used in accounting to classify operations - """ - - code = models.CharField( - _("code"), - max_length=16, - validators=[ - validators.RegexValidator( - r"^[0-9]*$", _("An accounting type code contains only numbers") - ) - ], - ) - label = models.CharField(_("label"), max_length=128) - movement_type = models.CharField( - _("movement type"), - choices=[ - ("CREDIT", _("Credit")), - ("DEBIT", _("Debit")), - ("NEUTRAL", _("Neutral")), - ], - max_length=12, - ) - - class Meta: - verbose_name = _("accounting type") - ordering = ["movement_type", "code"] - - def __str__(self): - return self.code + " - " + self.get_movement_type_display() + " - " + self.label - - def get_absolute_url(self): - return reverse("accounting:type_list") - - def is_owned_by(self, user): - """Check if that object can be edited by the given user.""" - if user.is_anonymous: - return False - return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - - -class SimplifiedAccountingType(models.Model): - """Simplified version of `AccountingType`.""" - - label = models.CharField(_("label"), max_length=128) - accounting_type = models.ForeignKey( - AccountingType, - related_name="simplified_types", - verbose_name=_("simplified accounting types"), - on_delete=models.CASCADE, - ) - - class Meta: - verbose_name = _("simplified type") - ordering = ["accounting_type__movement_type", "accounting_type__code"] - - def __str__(self): - return ( - f"{self.get_movement_type_display()} " - f"- {self.accounting_type.code} - {self.label}" - ) - - def get_absolute_url(self): - return reverse("accounting:simple_type_list") - - @property - def movement_type(self): - return self.accounting_type.movement_type - - def get_movement_type_display(self): - return self.accounting_type.get_movement_type_display() - - -class Label(models.Model): - """Label allow a club to sort its operations.""" - - name = models.CharField(_("label"), max_length=64) - club_account = models.ForeignKey( - ClubAccount, - related_name="labels", - verbose_name=_("club account"), - on_delete=models.CASCADE, - ) - - class Meta: - unique_together = ("name", "club_account") - - def __str__(self): - return "%s (%s)" % (self.name, self.club_account.name) - - def get_absolute_url(self): - return reverse( - "accounting:label_list", kwargs={"clubaccount_id": self.club_account.id} - ) - - def is_owned_by(self, user): - if user.is_anonymous: - return False - return self.club_account.is_owned_by(user) - - def can_be_edited_by(self, user): - return self.club_account.can_be_edited_by(user) - - def can_be_viewed_by(self, user): - return self.club_account.can_be_viewed_by(user) diff --git a/accounting/schemas.py b/accounting/schemas.py deleted file mode 100644 index 3d9edbcc..00000000 --- a/accounting/schemas.py +++ /dev/null @@ -1,15 +0,0 @@ -from ninja import ModelSchema - -from accounting.models import ClubAccount, Company - - -class ClubAccountSchema(ModelSchema): - class Meta: - model = ClubAccount - fields = ["id", "name"] - - -class CompanySchema(ModelSchema): - class Meta: - model = Company - fields = ["id", "name"] diff --git a/accounting/static/bundled/accounting/components/ajax-select-index.ts b/accounting/static/bundled/accounting/components/ajax-select-index.ts deleted file mode 100644 index 3fc93cf3..00000000 --- a/accounting/static/bundled/accounting/components/ajax-select-index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AjaxSelect } from "#core:core/components/ajax-select-base"; -import { registerComponent } from "#core:utils/web-components"; -import type { TomOption } from "tom-select/dist/types/types"; -import type { escape_html } from "tom-select/dist/types/utils"; -import { - type ClubAccountSchema, - type CompanySchema, - accountingSearchClubAccount, - accountingSearchCompany, -} from "#openapi"; - -@registerComponent("club-account-ajax-select") -export class ClubAccountAjaxSelect extends AjaxSelect { - protected valueField = "id"; - protected labelField = "name"; - protected searchField = ["code", "name"]; - - protected async search(query: string): Promise { - const resp = await accountingSearchClubAccount({ query: { search: query } }); - if (resp.data) { - return resp.data.results; - } - return []; - } - - protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) { - return `
- ${sanitize(item.name)} -
`; - } - - protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) { - return `${sanitize(item.name)}`; - } -} - -@registerComponent("company-ajax-select") -export class CompanyAjaxSelect extends AjaxSelect { - protected valueField = "id"; - protected labelField = "name"; - protected searchField = ["code", "name"]; - - protected async search(query: string): Promise { - const resp = await accountingSearchCompany({ query: { search: query } }); - if (resp.data) { - return resp.data.results; - } - return []; - } - - protected renderOption(item: CompanySchema, sanitize: typeof escape_html) { - return `
- ${sanitize(item.name)} -
`; - } - - protected renderItem(item: CompanySchema, sanitize: typeof escape_html) { - return `${sanitize(item.name)}`; - } -} diff --git a/accounting/templates/accounting/accountingtype_list.jinja b/accounting/templates/accounting/accountingtype_list.jinja deleted file mode 100644 index 7ae54014..00000000 --- a/accounting/templates/accounting/accountingtype_list.jinja +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Accounting type list{% endtrans %} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {% trans %}Accounting types{% endtrans %} -

-
-

{% trans %}New accounting type{% endtrans %}

- {% if accountingtype_list %} -

{% trans %}Accounting type list{% endtrans %}

-
    - {% for a in accountingtype_list %} -
  • {{ a }}
  • - {% endfor %} -
- {% else %} - {% trans %}There is no types in this website.{% endtrans %} - {% endif %} -
-{% endblock %} - diff --git a/accounting/templates/accounting/bank_account_details.jinja b/accounting/templates/accounting/bank_account_details.jinja deleted file mode 100644 index f1b1e056..00000000 --- a/accounting/templates/accounting/bank_account_details.jinja +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Bank account: {% endtrans %}{{ object.name }} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {{ object.name }} -

-
-

{% trans %}Bank account: {% endtrans %}{{ object.name }}

- {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and not object.club_accounts.exists() %} - {% trans %}Delete{% endtrans %} - {% endif %} -

{% trans %}Infos{% endtrans %}

-
    -
  • {% trans %}IBAN: {% endtrans %}{{ object.iban }}
  • -
  • {% trans %}Number: {% endtrans %}{{ object.number }}
  • -
-

{% trans %}New club account{% endtrans %}

- -
-{% endblock %} - - - diff --git a/accounting/templates/accounting/bank_account_list.jinja b/accounting/templates/accounting/bank_account_list.jinja deleted file mode 100644 index bb47cfca..00000000 --- a/accounting/templates/accounting/bank_account_list.jinja +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Bank account list{% endtrans %} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} -

- {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %} -

{% trans %}Manage simplified types{% endtrans %}

-

{% trans %}Manage accounting types{% endtrans %}

-

{% trans %}New bank account{% endtrans %}

- {% endif %} - {% if bankaccount_list %} -

{% trans %}Bank account list{% endtrans %}

- - {% else %} - {% trans %}There is no accounts in this website.{% endtrans %} - {% endif %} -
-{% endblock %} - - - diff --git a/accounting/templates/accounting/club_account_details.jinja b/accounting/templates/accounting/club_account_details.jinja deleted file mode 100644 index b6df130e..00000000 --- a/accounting/templates/accounting/club_account_details.jinja +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Club account:{% endtrans %} {{ object.name }} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {{object.bank_account }} > - {{ object }} -

-
-

{% trans %}Club account:{% endtrans %} {{ object.name }}

- {% if user.is_root and not object.journals.exists() %} - {% trans %}Delete{% endtrans %} - {% endif %} - {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %} -

{% trans %}New label{% endtrans %}

- {% endif %} -

{% trans %}Label list{% endtrans %}

- {% if not object.has_open_journal() %} -

{% trans %}New journal{% endtrans %}

- {% else %} -

{% trans %}You can not create new journal while you still have one opened{% endtrans %}

- {% endif %} - - - - - - - - - - - - - - {% for j in object.journals.all() %} - - - - {% if j.end_date %} - - {% else %} - - {% endif %} - - - {% if j.closed %} - - {% else %} - - {% endif %} - - - {% endfor %} - -
{% trans %}Name{% endtrans %}{% trans %}Start{% endtrans %}{% trans %}End{% endtrans %}{% trans %}Amount{% endtrans %}{% trans %}Effective amount{% endtrans %}{% trans %}Closed{% endtrans %}{% trans %}Actions{% endtrans %}
{{ j.name }}{{ j.start_date }}{{ j.end_date }} - {{ j.amount }} €{{ j.effective_amount }} €{% trans %}Yes{% endtrans %}{% trans %}No{% endtrans %} {% trans %}View{% endtrans %} - {% trans %}Edit{% endtrans %} - {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and j.operations.count() == 0 %} - {% trans %}Delete{% endtrans %} - {% endif %} -
-
-{% endblock %} diff --git a/accounting/templates/accounting/co_list.jinja b/accounting/templates/accounting/co_list.jinja deleted file mode 100644 index 1d357820..00000000 --- a/accounting/templates/accounting/co_list.jinja +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Company list{% endtrans %} -{% endblock %} - -{% block content %} -
- {% if user.is_root - or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - %} -

{% trans %}Create new company{% endtrans %}

-{% endif %} -
- - - - - - - - {% for o in object_list %} - - - - {% endfor %} - -
{% trans %}Companies{% endtrans %}
{{ o.get_display_name() }}
-
-{% endblock %} diff --git a/accounting/templates/accounting/journal_details.jinja b/accounting/templates/accounting/journal_details.jinja deleted file mode 100644 index 8393ca8b..00000000 --- a/accounting/templates/accounting/journal_details.jinja +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}General journal:{% endtrans %} {{ object.name }} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {{object.club_account.bank_account }} > - {{ object.club_account }} > - {{ object.name }} -

-
-

{% trans %}General journal:{% endtrans %} {{ object.name }}

-

{% trans %}New label{% endtrans %}

-

{% trans %}Label list{% endtrans %}

-

{% trans %}Company list{% endtrans %}

-

{% trans %}Amount: {% endtrans %}{{ object.amount }} € - - {% trans %}Effective amount: {% endtrans %}{{ object.effective_amount }} €

- {% if object.closed %} -

{% trans %}Journal is closed, you can not create operation{% endtrans %}

- {% else %} -

{% trans %}New operation{% endtrans %}

-
- {% endif %} -
- - - - - - - - - - - - - - - - - - - - {% for o in object.operations.all() %} - - - - - {% if o.accounting_type.movement_type == "DEBIT" %} - - {% else %} - - {% endif %} - - {% if o.target_type == "OTHER" %} - - {% else %} - - {% endif %} - - - {% if o.done %} - - {% else %} - - {% endif %} - - {% if o.invoice %} - - {% else %} - - {% endif %} - - - -{% endfor %} - -
{% trans %}Nb{% endtrans %}{% trans %}Date{% endtrans %}{% trans %}Label{% endtrans %}{% trans %}Amount{% endtrans %}{% trans %}Payment mode{% endtrans %}{% trans %}Target{% endtrans %}{% trans %}Code{% endtrans %}{% trans %}Nature{% endtrans %}{% trans %}Done{% endtrans %}{% trans %}Comment{% endtrans %}{% trans %}File{% endtrans %}{% trans %}Actions{% endtrans %}{% trans %}PDF{% endtrans %}
{{ o.number }}{{ o.date }}{{ o.label or "" }} {{ o.amount }} € {{ o.amount }} €{{ o.get_mode_display() }}{{ o.target_label }}{{ o.target.get_display_name() }}{{ o.accounting_type.code }}{{ o.accounting_type.label }}{% trans %}Yes{% endtrans %}{% trans %}No{% endtrans %}{{ o.remark }} - {% if not o.linked_operation and o.target_type == "ACCOUNT" and not o.target.has_open_journal() %} -

- {% trans %}Warning: this operation has no linked operation because the targeted club account has no opened journal.{% endtrans %} -

-

- {% trans url=o.target.get_absolute_url() %}Open a journal in this club account, then save this operation again to make the linked operation.{% endtrans %} -

- {% endif %} -
{{ o.invoice.name }}- - {% - if o.journal.club_account.bank_account.name not in ["AE TI", "TI"] - or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - %} - {% if not o.journal.closed %} - {% trans %}Edit{% endtrans %} - {% endif %} - {% endif %} - {% trans %}Generate{% endtrans %}
-
-
-{% endblock %} diff --git a/accounting/templates/accounting/journal_statement_accounting.jinja b/accounting/templates/accounting/journal_statement_accounting.jinja deleted file mode 100644 index 5641e78b..00000000 --- a/accounting/templates/accounting/journal_statement_accounting.jinja +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}General journal:{% endtrans %} {{ object.name }} -{% endblock %} - - -{% block content %} -
-

{% trans %}Accounting statement: {% endtrans %} {{ object.name }}

- - - - - - - - - - {% for k,v in statement.items() %} - - - - - {% endfor %} - - -
{% trans %}Operation type{% endtrans %}{% trans %}Sum{% endtrans %}
{{ k }}{{ "%.2f" % v }}
- -

{% trans %}Amount: {% endtrans %}{{ "%.2f" % object.amount }} €

-

{% trans %}Effective amount: {% endtrans %}{{ "%.2f" %object.effective_amount }} €

-
-{% endblock %} diff --git a/accounting/templates/accounting/journal_statement_nature.jinja b/accounting/templates/accounting/journal_statement_nature.jinja deleted file mode 100644 index 0a149326..00000000 --- a/accounting/templates/accounting/journal_statement_nature.jinja +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}General journal:{% endtrans %} {{ object.name }} -{% endblock %} - -{% macro display_tables(dict) %} -
-
{% trans %}Credit{% endtrans %}
- - - - - - - - - {% for k,v in dict['CREDIT'].items() %} - - - - - {% endfor %} - -
{% trans %}Nature of operation{% endtrans %}{% trans %}Sum{% endtrans %}
{{ k }}{{ "%.2f" % v }}
- {% trans %}Total: {% endtrans %}{{ "%.2f" % dict['CREDIT_sum'] }} - -
{% trans %}Debit{% endtrans %}
- - - - - - - - - {% for k,v in dict['DEBIT'].items() %} - - - - - {% endfor %} - -
{% trans %}Nature of operation{% endtrans %}{% trans %}Sum{% endtrans %}
{{ k }}{{ "%.2f" % v }}
- {% trans %}Total: {% endtrans %}{{ "%.2f" % dict['DEBIT_sum'] }} -{% endmacro %} - -{% block content %} -

{% trans %}Statement by nature: {% endtrans %} {{ object.name }}

- - {% for k,v in statement.items() %} -

{{ k }} : {{ "%.2f" % (v['CREDIT_sum'] - v['DEBIT_sum']) }}

- {{ display_tables(v) }} -
- {% endfor %} -
-{% endblock %} diff --git a/accounting/templates/accounting/journal_statement_person.jinja b/accounting/templates/accounting/journal_statement_person.jinja deleted file mode 100644 index 76482647..00000000 --- a/accounting/templates/accounting/journal_statement_person.jinja +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}General journal:{% endtrans %} {{ object.name }} -{% endblock %} - - -{% block content %} -
-

{% trans %}Statement by person: {% endtrans %} {{ object.name }}

- -

{% trans %}Credit{% endtrans %}

- - - - - - - - - - {% for key in credit_statement.keys() %} - - {% if key.target_type == "OTHER" %} - - {% elif key %} - - {% else %} - - {% endif %} - - - {% endfor %} - - -
{% trans %}Target of the operation{% endtrans %}{% trans %}Sum{% endtrans %}
{{ o.target_label }}{{ key.get_display_name() }}{{ "%.2f" % credit_statement[key] }}
- -

Total : {{ "%.2f" % total_credit }}

- -

{% trans %}Debit{% endtrans %}

- - - - - - - - - - {% for key in debit_statement.keys() %} - - {% if key.target_type == "OTHER" %} - - {% elif key %} - - {% else %} - - {% endif %} - - - {% endfor %} - - -
{% trans %}Target of the operation{% endtrans %}{% trans %}Sum{% endtrans %}
{{ o.target_label }}{{ key.get_display_name() }}{{ "%.2f" % debit_statement[key] }}
- -

Total : {{ "%.2f" % total_debit }}

-
-{% endblock %} diff --git a/accounting/templates/accounting/label_list.jinja b/accounting/templates/accounting/label_list.jinja deleted file mode 100644 index 6b5a9b8b..00000000 --- a/accounting/templates/accounting/label_list.jinja +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Label list{% endtrans %} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {{object.bank_account }} > - {{ object }} -

-
-

{% trans %}Back to club account{% endtrans %}

- {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %} -

{% trans %}New label{% endtrans %}

- {% endif %} - {% if object.labels.all() %} -

{% trans %}Label list{% endtrans %}

-
    - {% for l in object.labels.all() %} -
  • {{ l }} - {% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %} - - - {% trans %}Delete{% endtrans %} - {% endif %} -
  • - {% endfor %} -
- {% else %} - {% trans %}There is no label in this club account.{% endtrans %} - {% endif %} -
-{% endblock %} - diff --git a/accounting/templates/accounting/operation_edit.jinja b/accounting/templates/accounting/operation_edit.jinja deleted file mode 100644 index 4a75cb83..00000000 --- a/accounting/templates/accounting/operation_edit.jinja +++ /dev/null @@ -1,123 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Edit operation{% endtrans %} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {{object.club_account.bank_account }} > - {{ object.club_account }} > - {{ object.name }} > - {% trans %}Edit operation{% endtrans %} -

-
-

{% trans %}Edit operation{% endtrans %}

-
- {% csrf_token %} - {{ form.non_field_errors() }} - {{ form.journal }} - {{ form.target_id }} -

{{ form.amount.errors }} {{ form.amount }}

-

{{ form.remark.errors }} {{ form.remark }}

-
- {% trans %}Warning: if you select Account, the opposite operation will be created in the target account. If you don't want that, select Club instead of Account.{% endtrans %} -

{{ form.target_type.errors }} {{ form.target_type }}

- {{ form.user }} - {{ form.club }} - {{ form.club_account }} - {{ form.company }} - {{ form.target_label }} - {{ form.need_link }} -

{{ form.date.errors }} {{ form.date }}

-

{{ form.mode.errors }} {{ form.mode }}

-

{{ form.cheque_number.errors }} {{ - form.cheque_number }}

-

{{ form.invoice.errors }} {{ form.invoice }}

-

{{ form.simpleaccounting_type.errors }} {{ form.simpleaccounting_type }}

-

{{ form.accounting_type.errors }} {{ - form.accounting_type }}

-

{{ form.label.errors }} {{ form.label }}

-

{{ form.done.errors }} {{ form.done }}

- {% if form.instance.linked_operation %} - {% set obj = form.instance.linked_operation %} -

{% trans %}Linked operation:{% endtrans %}
- - {{obj.journal.club_account.bank_account }} > - {{ obj.journal.club_account }} > - {{ obj.journal }} > - n°{{ obj.number }} -

- {% endif %} -

-
-{% endblock %} - -{% block script %} - {{ super() }} - -
-{% endblock %} - - diff --git a/accounting/templates/accounting/simplifiedaccountingtype_list.jinja b/accounting/templates/accounting/simplifiedaccountingtype_list.jinja deleted file mode 100644 index da72a370..00000000 --- a/accounting/templates/accounting/simplifiedaccountingtype_list.jinja +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Simplified type list{% endtrans %} -{% endblock %} - -{% block content %} -
-

- {% trans %}Accounting{% endtrans %} > - {% trans %}Simplified types{% endtrans %} -

-
-

{% trans %}New simplified type{% endtrans %}

- {% if simplifiedaccountingtype_list %} -

{% trans %}Simplified type list{% endtrans %}

-
    - {% for a in simplifiedaccountingtype_list %} -
  • {{ a }}
  • - {% endfor %} -
- {% else %} - {% trans %}There is no types in this website.{% endtrans %} - {% endif %} -
-{% endblock %} - diff --git a/accounting/tests.py b/accounting/tests.py deleted file mode 100644 index 1140acc7..00000000 --- a/accounting/tests.py +++ /dev/null @@ -1,292 +0,0 @@ -# -# Copyright 2023 © AE UTBM -# ae@utbm.fr / ae.info@utbm.fr -# -# This file is part of the website of the UTBM Student Association (AE UTBM), -# https://ae.utbm.fr. -# -# You can find the source code of the website at https://github.com/ae-utbm/sith -# -# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) -# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE -# OR WITHIN THE LOCAL FILE "LICENSE" -# -# - -from datetime import date, timedelta - -from django.test import TestCase -from django.urls import reverse - -from accounting.models import ( - AccountingType, - GeneralJournal, - Label, - Operation, - SimplifiedAccountingType, -) -from core.models import User - - -class TestRefoundAccount(TestCase): - @classmethod - def setUpTestData(cls): - cls.skia = User.objects.get(username="skia") - # reffil skia's account - cls.skia.customer.amount = 800 - cls.skia.customer.save() - cls.refound_account_url = reverse("accounting:refound_account") - - def test_permission_denied(self): - self.client.force_login(User.objects.get(username="guy")) - response_post = self.client.post( - self.refound_account_url, {"user": self.skia.id} - ) - response_get = self.client.get(self.refound_account_url) - assert response_get.status_code == 403 - assert response_post.status_code == 403 - - def test_root_granteed(self): - self.client.force_login(User.objects.get(username="root")) - response = self.client.post(self.refound_account_url, {"user": self.skia.id}) - self.assertRedirects(response, self.refound_account_url) - self.skia.refresh_from_db() - response = self.client.get(self.refound_account_url) - assert response.status_code == 200 - assert '
' in str(response.content) - assert self.skia.customer.amount == 0 - - def test_comptable_granteed(self): - self.client.force_login(User.objects.get(username="comptable")) - response = self.client.post(self.refound_account_url, {"user": self.skia.id}) - self.assertRedirects(response, self.refound_account_url) - self.skia.refresh_from_db() - response = self.client.get(self.refound_account_url) - assert response.status_code == 200 - assert '' in str(response.content) - assert self.skia.customer.amount == 0 - - -class TestJournal(TestCase): - @classmethod - def setUpTestData(cls): - cls.journal = GeneralJournal.objects.get(id=1) - - def test_permission_granted(self): - self.client.force_login(User.objects.get(username="comptable")) - response_get = self.client.get( - reverse("accounting:journal_details", args=[self.journal.id]) - ) - - assert response_get.status_code == 200 - assert "M\\xc3\\xa9thode de paiement" in str(response_get.content) - - def test_permission_not_granted(self): - self.client.force_login(User.objects.get(username="skia")) - response_get = self.client.get( - reverse("accounting:journal_details", args=[self.journal.id]) - ) - - assert response_get.status_code == 403 - assert "M\xc3\xa9thode de paiement" not in str(response_get.content) - - -class TestOperation(TestCase): - def setUp(self): - self.tomorrow_formatted = (date.today() + timedelta(days=1)).strftime( - "%d/%m/%Y" - ) - self.journal = GeneralJournal.objects.filter(id=1).first() - self.skia = User.objects.filter(username="skia").first() - at = AccountingType( - code="443", label="Ce code n'existe pas", movement_type="CREDIT" - ) - at.save() - label = Label.objects.create(club_account=self.journal.club_account, name="bob") - self.client.force_login(User.objects.get(username="comptable")) - self.op1 = Operation( - journal=self.journal, - date=date.today(), - amount=1, - remark="Test bilan", - mode="CASH", - done=True, - label=label, - accounting_type=at, - target_type="USER", - target_id=self.skia.id, - ) - self.op1.save() - self.op2 = Operation( - journal=self.journal, - date=date.today(), - amount=2, - remark="Test bilan", - mode="CASH", - done=True, - label=label, - accounting_type=at, - target_type="USER", - target_id=self.skia.id, - ) - self.op2.save() - - def test_new_operation(self): - at = AccountingType.objects.get(code="604") - response = self.client.post( - reverse("accounting:op_new", args=[self.journal.id]), - { - "amount": 30, - "remark": "Un gros test", - "journal": self.journal.id, - "target_type": "OTHER", - "target_id": "", - "target_label": "Le fantome de la nuit", - "date": self.tomorrow_formatted, - "mode": "CASH", - "cheque_number": "", - "invoice": "", - "simpleaccounting_type": "", - "accounting_type": at.id, - "label": "", - "done": False, - }, - ) - self.assertFalse(response.status_code == 403) - self.assertTrue( - self.journal.operations.filter( - target_label="Le fantome de la nuit" - ).exists() - ) - response_get = self.client.get( - reverse("accounting:journal_details", args=[self.journal.id]) - ) - self.assertTrue("Le fantome de la nuit" in str(response_get.content)) - - def test_bad_new_operation(self): - AccountingType.objects.get(code="604") - response = self.client.post( - reverse("accounting:op_new", args=[self.journal.id]), - { - "amount": 30, - "remark": "Un gros test", - "journal": self.journal.id, - "target_type": "OTHER", - "target_id": "", - "target_label": "Le fantome de la nuit", - "date": self.tomorrow_formatted, - "mode": "CASH", - "cheque_number": "", - "invoice": "", - "simpleaccounting_type": "", - "accounting_type": "", - "label": "", - "done": False, - }, - ) - self.assertTrue( - "Vous devez fournir soit un type comptable simplifi\\xc3\\xa9 ou un type comptable standard" - in str(response.content) - ) - - def test_new_operation_not_authorized(self): - self.client.force_login(self.skia) - at = AccountingType.objects.filter(code="604").first() - response = self.client.post( - reverse("accounting:op_new", args=[self.journal.id]), - { - "amount": 30, - "remark": "Un gros test", - "journal": self.journal.id, - "target_type": "OTHER", - "target_id": "", - "target_label": "Le fantome du jour", - "date": self.tomorrow_formatted, - "mode": "CASH", - "cheque_number": "", - "invoice": "", - "simpleaccounting_type": "", - "accounting_type": at.id, - "label": "", - "done": False, - }, - ) - self.assertTrue(response.status_code == 403) - self.assertFalse( - self.journal.operations.filter(target_label="Le fantome du jour").exists() - ) - - def test_operation_simple_accounting(self): - sat = SimplifiedAccountingType.objects.all().first() - response = self.client.post( - reverse("accounting:op_new", args=[self.journal.id]), - { - "amount": 23, - "remark": "Un gros test", - "journal": self.journal.id, - "target_type": "OTHER", - "target_id": "", - "target_label": "Le fantome de l'aurore", - "date": self.tomorrow_formatted, - "mode": "CASH", - "cheque_number": "", - "invoice": "", - "simpleaccounting_type": sat.id, - "accounting_type": "", - "label": "", - "done": False, - }, - ) - assert response.status_code != 403 - assert self.journal.operations.filter(amount=23).exists() - response_get = self.client.get( - reverse("accounting:journal_details", args=[self.journal.id]) - ) - assert "Le fantome de l'aurore" in str(response_get.content) - - assert ( - self.journal.operations.filter(amount=23) - .values("accounting_type") - .first()["accounting_type"] - == AccountingType.objects.filter(code=6).values("id").first()["id"] - ) - - def test_nature_statement(self): - response = self.client.get( - reverse("accounting:journal_nature_statement", args=[self.journal.id]) - ) - self.assertContains(response, "bob (Troll Penché) : 3.00", status_code=200) - - def test_person_statement(self): - response = self.client.get( - reverse("accounting:journal_person_statement", args=[self.journal.id]) - ) - self.assertContains(response, "Total : 5575.72", status_code=200) - self.assertContains(response, "Total : 71.42") - content = response.content.decode() - self.assertInHTML( - """S' Kia3.00""", content - ) - self.assertInHTML( - """S' Kia823.00""", content - ) - - def test_accounting_statement(self): - response = self.client.get( - reverse("accounting:journal_accounting_statement", args=[self.journal.id]) - ) - assert response.status_code == 200 - self.assertInHTML( - """ - - 443 - Crédit - Ce code n'existe pas - 3.00 - """, - response.content.decode(), - ) - self.assertContains( - response, - """ -

Montant : -5504.30 €

-

Montant effectif: -5504.30 €

""", - ) diff --git a/accounting/urls.py b/accounting/urls.py deleted file mode 100644 index f1917462..00000000 --- a/accounting/urls.py +++ /dev/null @@ -1,173 +0,0 @@ -# -# Copyright 2023 © AE UTBM -# ae@utbm.fr / ae.info@utbm.fr -# -# This file is part of the website of the UTBM Student Association (AE UTBM), -# https://ae.utbm.fr. -# -# You can find the source code of the website at https://github.com/ae-utbm/sith -# -# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) -# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE -# OR WITHIN THE LOCAL FILE "LICENSE" -# -# - -from django.urls import path - -from accounting.views import ( - AccountingTypeCreateView, - AccountingTypeEditView, - AccountingTypeListView, - BankAccountCreateView, - BankAccountDeleteView, - BankAccountDetailView, - BankAccountEditView, - BankAccountListView, - ClubAccountCreateView, - ClubAccountDeleteView, - ClubAccountDetailView, - ClubAccountEditView, - CompanyCreateView, - CompanyEditView, - CompanyListView, - JournalAccountingStatementView, - JournalCreateView, - JournalDeleteView, - JournalDetailView, - JournalEditView, - JournalNatureStatementView, - JournalPersonStatementView, - LabelCreateView, - LabelDeleteView, - LabelEditView, - LabelListView, - OperationCreateView, - OperationEditView, - OperationPDFView, - RefoundAccountView, - SimplifiedAccountingTypeCreateView, - SimplifiedAccountingTypeEditView, - SimplifiedAccountingTypeListView, -) - -urlpatterns = [ - # Accounting types - path( - "simple_type/", - SimplifiedAccountingTypeListView.as_view(), - name="simple_type_list", - ), - path( - "simple_type/create/", - SimplifiedAccountingTypeCreateView.as_view(), - name="simple_type_new", - ), - path( - "simple_type//edit/", - SimplifiedAccountingTypeEditView.as_view(), - name="simple_type_edit", - ), - # Accounting types - path("type/", AccountingTypeListView.as_view(), name="type_list"), - path("type/create/", AccountingTypeCreateView.as_view(), name="type_new"), - path( - "type//edit/", - AccountingTypeEditView.as_view(), - name="type_edit", - ), - # Bank accounts - path("", BankAccountListView.as_view(), name="bank_list"), - path("bank/create", BankAccountCreateView.as_view(), name="bank_new"), - path( - "bank//", - BankAccountDetailView.as_view(), - name="bank_details", - ), - path( - "bank//edit/", - BankAccountEditView.as_view(), - name="bank_edit", - ), - path( - "bank//delete/", - BankAccountDeleteView.as_view(), - name="bank_delete", - ), - # Club accounts - path("club/create/", ClubAccountCreateView.as_view(), name="club_new"), - path( - "club//", - ClubAccountDetailView.as_view(), - name="club_details", - ), - path( - "club//edit/", - ClubAccountEditView.as_view(), - name="club_edit", - ), - path( - "club//delete/", - ClubAccountDeleteView.as_view(), - name="club_delete", - ), - # Journals - path("journal/create/", JournalCreateView.as_view(), name="journal_new"), - path( - "journal//", - JournalDetailView.as_view(), - name="journal_details", - ), - path( - "journal//edit/", - JournalEditView.as_view(), - name="journal_edit", - ), - path( - "journal//delete/", - JournalDeleteView.as_view(), - name="journal_delete", - ), - path( - "journal//statement/nature/", - JournalNatureStatementView.as_view(), - name="journal_nature_statement", - ), - path( - "journal//statement/person/", - JournalPersonStatementView.as_view(), - name="journal_person_statement", - ), - path( - "journal//statement/accounting/", - JournalAccountingStatementView.as_view(), - name="journal_accounting_statement", - ), - # Operations - path( - "operation/create//", - OperationCreateView.as_view(), - name="op_new", - ), - path("operation//", OperationEditView.as_view(), name="op_edit"), - path("operation//pdf/", OperationPDFView.as_view(), name="op_pdf"), - # Companies - path("company/list/", CompanyListView.as_view(), name="co_list"), - path("company/create/", CompanyCreateView.as_view(), name="co_new"), - path("company//", CompanyEditView.as_view(), name="co_edit"), - # Labels - path("label/new/", LabelCreateView.as_view(), name="label_new"), - path( - "label//", - LabelListView.as_view(), - name="label_list", - ), - path("label//edit/", LabelEditView.as_view(), name="label_edit"), - path( - "label//delete/", - LabelDeleteView.as_view(), - name="label_delete", - ), - # User account - path("refound/account/", RefoundAccountView.as_view(), name="refound_account"), -] diff --git a/accounting/views.py b/accounting/views.py deleted file mode 100644 index f9fd8412..00000000 --- a/accounting/views.py +++ /dev/null @@ -1,896 +0,0 @@ -# -# Copyright 2023 © AE UTBM -# ae@utbm.fr / ae.info@utbm.fr -# -# This file is part of the website of the UTBM Student Association (AE UTBM), -# https://ae.utbm.fr. -# -# You can find the source code of the website at https://github.com/ae-utbm/sith -# -# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) -# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE -# OR WITHIN THE LOCAL FILE "LICENSE" -# -# - -import collections - -from django import forms -from django.conf import settings -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.core.exceptions import PermissionDenied, ValidationError -from django.db import transaction -from django.db.models import Sum -from django.forms import HiddenInput -from django.forms.models import modelform_factory -from django.http import HttpResponse -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, ListView -from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView - -from accounting.models import ( - AccountingType, - BankAccount, - ClubAccount, - Company, - GeneralJournal, - Label, - Operation, - SimplifiedAccountingType, -) -from accounting.widgets.select import ( - AutoCompleteSelectClubAccount, - AutoCompleteSelectCompany, -) -from club.models import Club -from club.widgets.select import AutoCompleteSelectClub -from core.auth.mixins import ( - CanCreateMixin, - CanEditMixin, - CanEditPropMixin, - CanViewMixin, -) -from core.models import User -from core.views.forms import SelectDate, SelectFile -from core.views.mixins import TabedViewMixin -from core.views.widgets.select import AutoCompleteSelectUser -from counter.models import Counter, Product, Selling - -# Main accounting view - - -class BankAccountListView(CanViewMixin, ListView): - """A list view for the admins.""" - - model = BankAccount - template_name = "accounting/bank_account_list.jinja" - ordering = ["name"] - - -# Simplified accounting types - - -class SimplifiedAccountingTypeListView(CanViewMixin, ListView): - """A list view for the admins.""" - - model = SimplifiedAccountingType - template_name = "accounting/simplifiedaccountingtype_list.jinja" - - -class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView): - """An edit view for the admins.""" - - model = SimplifiedAccountingType - pk_url_kwarg = "type_id" - fields = ["label", "accounting_type"] - template_name = "core/edit.jinja" - - -class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView): - """Create an accounting type (for the admins).""" - - model = SimplifiedAccountingType - fields = ["label", "accounting_type"] - template_name = "core/create.jinja" - permission_required = "accounting.add_simplifiedaccountingtype" - - -# Accounting types - - -class AccountingTypeListView(CanViewMixin, ListView): - """A list view for the admins.""" - - model = AccountingType - template_name = "accounting/accountingtype_list.jinja" - - -class AccountingTypeEditView(CanViewMixin, UpdateView): - """An edit view for the admins.""" - - model = AccountingType - pk_url_kwarg = "type_id" - fields = ["code", "label", "movement_type"] - template_name = "core/edit.jinja" - - -class AccountingTypeCreateView(PermissionRequiredMixin, CreateView): - """Create an accounting type (for the admins).""" - - model = AccountingType - fields = ["code", "label", "movement_type"] - template_name = "core/create.jinja" - permission_required = "accounting.add_accountingtype" - - -# BankAccount views - - -class BankAccountEditView(CanViewMixin, UpdateView): - """An edit view for the admins.""" - - model = BankAccount - pk_url_kwarg = "b_account_id" - fields = ["name", "iban", "number", "club"] - template_name = "core/edit.jinja" - - -class BankAccountDetailView(CanViewMixin, DetailView): - """A detail view, listing every club account.""" - - model = BankAccount - pk_url_kwarg = "b_account_id" - template_name = "accounting/bank_account_details.jinja" - - -class BankAccountCreateView(CanCreateMixin, CreateView): - """Create a bank account (for the admins).""" - - model = BankAccount - fields = ["name", "club", "iban", "number"] - template_name = "core/create.jinja" - - -class BankAccountDeleteView( - CanEditPropMixin, DeleteView -): # TODO change Delete to Close - """Delete a bank account (for the admins).""" - - model = BankAccount - pk_url_kwarg = "b_account_id" - template_name = "core/delete_confirm.jinja" - success_url = reverse_lazy("accounting:bank_list") - - -# ClubAccount views - - -class ClubAccountEditView(CanViewMixin, UpdateView): - """An edit view for the admins.""" - - model = ClubAccount - pk_url_kwarg = "c_account_id" - fields = ["name", "club", "bank_account"] - template_name = "core/edit.jinja" - - -class ClubAccountDetailView(CanViewMixin, DetailView): - """A detail view, listing every journal.""" - - model = ClubAccount - pk_url_kwarg = "c_account_id" - template_name = "accounting/club_account_details.jinja" - - -class ClubAccountCreateView(CanCreateMixin, CreateView): - """Create a club account (for the admins).""" - - model = ClubAccount - fields = ["name", "club", "bank_account"] - template_name = "core/create.jinja" - - def get_initial(self): - ret = super().get_initial() - if "parent" in self.request.GET: - obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first() - if obj is not None: - ret["bank_account"] = obj.id - return ret - - -class ClubAccountDeleteView( - CanEditPropMixin, DeleteView -): # TODO change Delete to Close - """Delete a club account (for the admins).""" - - model = ClubAccount - pk_url_kwarg = "c_account_id" - template_name = "core/delete_confirm.jinja" - success_url = reverse_lazy("accounting:bank_list") - - -# Journal views - - -class JournalTabsMixin(TabedViewMixin): - def get_tabs_title(self): - return _("Journal") - - def get_list_of_tabs(self): - return [ - { - "url": reverse( - "accounting:journal_details", kwargs={"j_id": self.object.id} - ), - "slug": "journal", - "name": _("Journal"), - }, - { - "url": reverse( - "accounting:journal_nature_statement", - kwargs={"j_id": self.object.id}, - ), - "slug": "nature_statement", - "name": _("Statement by nature"), - }, - { - "url": reverse( - "accounting:journal_person_statement", - kwargs={"j_id": self.object.id}, - ), - "slug": "person_statement", - "name": _("Statement by person"), - }, - { - "url": reverse( - "accounting:journal_accounting_statement", - kwargs={"j_id": self.object.id}, - ), - "slug": "accounting_statement", - "name": _("Accounting statement"), - }, - ] - - -class JournalCreateView(CanCreateMixin, CreateView): - """Create a general journal.""" - - model = GeneralJournal - form_class = modelform_factory( - GeneralJournal, - fields=["name", "start_date", "club_account"], - widgets={"start_date": SelectDate}, - ) - template_name = "core/create.jinja" - - def get_initial(self): - ret = super().get_initial() - if "parent" in self.request.GET: - obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first() - if obj is not None: - ret["club_account"] = obj.id - return ret - - -class JournalDetailView(JournalTabsMixin, CanViewMixin, DetailView): - """A detail view, listing every operation.""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - template_name = "accounting/journal_details.jinja" - current_tab = "journal" - - -class JournalEditView(CanEditMixin, UpdateView): - """Update a general journal.""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - fields = ["name", "start_date", "end_date", "club_account", "closed"] - template_name = "core/edit.jinja" - - -class JournalDeleteView(CanEditPropMixin, DeleteView): - """Delete a club account (for the admins).""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - template_name = "core/delete_confirm.jinja" - success_url = reverse_lazy("accounting:club_details") - - def dispatch(self, request, *args, **kwargs): - self.object = self.get_object() - if self.object.operations.count() == 0: - return super().dispatch(request, *args, **kwargs) - else: - raise PermissionDenied - - -# Operation views - - -class OperationForm(forms.ModelForm): - class Meta: - model = Operation - fields = [ - "amount", - "remark", - "journal", - "target_type", - "target_id", - "target_label", - "date", - "mode", - "cheque_number", - "invoice", - "simpleaccounting_type", - "accounting_type", - "label", - "done", - ] - widgets = { - "journal": HiddenInput, - "target_id": HiddenInput, - "date": SelectDate, - "invoice": SelectFile, - } - - user = forms.ModelChoiceField( - help_text=None, - required=False, - widget=AutoCompleteSelectUser, - queryset=User.objects.all(), - ) - club_account = forms.ModelChoiceField( - help_text=None, - required=False, - widget=AutoCompleteSelectClubAccount, - queryset=ClubAccount.objects.all(), - ) - club = forms.ModelChoiceField( - help_text=None, - required=False, - widget=AutoCompleteSelectClub, - queryset=Club.objects.all(), - ) - company = forms.ModelChoiceField( - help_text=None, - required=False, - widget=AutoCompleteSelectCompany, - queryset=Company.objects.all(), - ) - need_link = forms.BooleanField( - label=_("Link this operation to the target account"), - required=False, - initial=False, - ) - - def __init__(self, *args, **kwargs): - club_account = kwargs.pop("club_account", None) - super().__init__(*args, **kwargs) - if club_account: - self.fields["label"].queryset = club_account.labels.order_by("name").all() - if self.instance.target_type == "USER": - self.fields["user"].initial = self.instance.target_id - elif self.instance.target_type == "ACCOUNT": - self.fields["club_account"].initial = self.instance.target_id - elif self.instance.target_type == "CLUB": - self.fields["club"].initial = self.instance.target_id - elif self.instance.target_type == "COMPANY": - self.fields["company"].initial = self.instance.target_id - - def clean(self): - self.cleaned_data = super().clean() - if "target_type" in self.cleaned_data: - if ( - self.cleaned_data.get("user") is None - and self.cleaned_data.get("club") is None - and self.cleaned_data.get("club_account") is None - and self.cleaned_data.get("company") is None - and self.cleaned_data.get("target_label") == "" - ): - self.add_error( - "target_type", ValidationError(_("The target must be set.")) - ) - else: - if self.cleaned_data["target_type"] == "USER": - self.cleaned_data["target_id"] = self.cleaned_data["user"].id - elif self.cleaned_data["target_type"] == "ACCOUNT": - self.cleaned_data["target_id"] = self.cleaned_data[ - "club_account" - ].id - elif self.cleaned_data["target_type"] == "CLUB": - self.cleaned_data["target_id"] = self.cleaned_data["club"].id - elif self.cleaned_data["target_type"] == "COMPANY": - self.cleaned_data["target_id"] = self.cleaned_data["company"].id - - if self.cleaned_data.get("amount") is None: - self.add_error("amount", ValidationError(_("The amount must be set."))) - - return self.cleaned_data - - def save(self): - ret = super().save() - if ( - self.instance.target_type == "ACCOUNT" - and not self.instance.linked_operation - and self.instance.target.has_open_journal() - and self.cleaned_data["need_link"] - ): - inst = self.instance - club_account = inst.target - acc_type = ( - AccountingType.objects.exclude(movement_type="NEUTRAL") - .exclude(movement_type=inst.accounting_type.movement_type) - .order_by("code") - .first() - ) # Select a random opposite accounting type - op = Operation( - journal=club_account.get_open_journal(), - amount=inst.amount, - date=inst.date, - remark=inst.remark, - mode=inst.mode, - cheque_number=inst.cheque_number, - invoice=inst.invoice, - done=False, # Has to be checked by hand - simpleaccounting_type=None, - accounting_type=acc_type, - target_type="ACCOUNT", - target_id=inst.journal.club_account.id, - target_label="", - linked_operation=inst, - ) - op.save() - self.instance.linked_operation = op - self.save() - return ret - - -class OperationCreateView(CanCreateMixin, CreateView): - """Create an operation.""" - - model = Operation - form_class = OperationForm - template_name = "accounting/operation_edit.jinja" - - def get_form(self, form_class=None): - self.journal = GeneralJournal.objects.filter(id=self.kwargs["j_id"]).first() - ca = self.journal.club_account if self.journal else None - return self.form_class(club_account=ca, **self.get_form_kwargs()) - - def get_initial(self): - ret = super().get_initial() - if self.journal is not None: - ret["journal"] = self.journal.id - return ret - - def get_context_data(self, **kwargs): - """Add journal to the context.""" - kwargs = super().get_context_data(**kwargs) - if self.journal: - kwargs["object"] = self.journal - return kwargs - - -class OperationEditView(CanEditMixin, UpdateView): - """An edit view, working as detail for the moment.""" - - model = Operation - pk_url_kwarg = "op_id" - form_class = OperationForm - template_name = "accounting/operation_edit.jinja" - - def get_context_data(self, **kwargs): - """Add journal to the context.""" - kwargs = super().get_context_data(**kwargs) - kwargs["object"] = self.object.journal - return kwargs - - -class OperationPDFView(CanViewMixin, DetailView): - """Display the PDF of a given operation.""" - - model = Operation - pk_url_kwarg = "op_id" - - def get(self, request, *args, **kwargs): - from reportlab.lib import colors - from reportlab.lib.pagesizes import letter - from reportlab.lib.units import cm - from reportlab.lib.utils import ImageReader - from reportlab.pdfbase import pdfmetrics - from reportlab.pdfbase.ttfonts import TTFont - from reportlab.pdfgen import canvas - from reportlab.platypus import Table, TableStyle - - pdfmetrics.registerFont(TTFont("DejaVu", "DejaVuSerif.ttf")) - - self.object = self.get_object() - amount = self.object.amount - remark = self.object.remark - nature = self.object.accounting_type.movement_type - num = self.object.number - date = self.object.date - mode = self.object.mode - club_name = self.object.journal.club_account.name - ti = self.object.journal.name - op_label = self.object.label - club_address = self.object.journal.club_account.club.address - id_op = self.object.id - - if self.object.target_type == "OTHER": - target = self.object.target_label - else: - target = self.object.target.get_display_name() - - response = HttpResponse(content_type="application/pdf") - response["Content-Disposition"] = 'filename="op-%d(%s_on_%s).pdf"' % ( - num, - ti, - club_name, - ) - p = canvas.Canvas(response) - - p.setFont("DejaVu", 12) - - p.setTitle("%s %d" % (_("Operation"), num)) - width, height = letter - im = ImageReader("core/static/core/img/logo.jpg") - iw, ih = im.getSize() - p.drawImage(im, 40, height - 50, width=iw / 2, height=ih / 2) - - labelStr = [["%s %s - %s %s" % (_("Journal"), ti, _("Operation"), num)]] - - label = Table(labelStr, colWidths=[150], rowHeights=[20]) - - label.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "RIGHT")])) - w, h = label.wrapOn(label, 0, 0) - label.drawOn(p, width - 180, height) - - p.drawString( - 90, height - 100, _("Financial proof: ") + "OP%010d" % (id_op) - ) # Justificatif du libellé - p.drawString( - 90, height - 130, _("Club: %(club_name)s") % ({"club_name": club_name}) - ) - p.drawString( - 90, - height - 160, - _("Label: %(op_label)s") - % {"op_label": op_label if op_label is not None else ""}, - ) - p.drawString(90, height - 190, _("Date: %(date)s") % {"date": date}) - - data = [] - - data += [ - ["%s" % (_("Credit").upper() if nature == "CREDIT" else _("Debit").upper())] - ] - - data += [[_("Amount: %(amount).2f €") % {"amount": amount}]] - - payment_mode = "" - for m in settings.SITH_ACCOUNTING_PAYMENT_METHOD: - if m[0] == mode: - payment_mode += "[\u00d7]" - else: - payment_mode += "[ ]" - payment_mode += " %s\n" % (m[1]) - - data += [[payment_mode]] - - data += [ - [ - "%s : %s" - % (_("Debtor") if nature == "CREDIT" else _("Creditor"), target), - "", - ] - ] - - data += [["%s \n%s" % (_("Comment:"), remark)]] - - t = Table( - data, colWidths=[(width - 90 * 2) / 2] * 2, rowHeights=[20, 20, 70, 20, 80] - ) - t.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (-2, -1), (-1, -1), "TOP"), - ("VALIGN", (0, 0), (-1, -2), "MIDDLE"), - ("INNERGRID", (0, 0), (-1, -1), 0.25, colors.black), - ("SPAN", (0, 0), (1, 0)), # line DEBIT/CREDIT - ("SPAN", (0, 1), (1, 1)), # line amount - ("SPAN", (-2, -1), (-1, -1)), # line comment - ("SPAN", (0, -2), (-1, -2)), # line creditor/debtor - ("SPAN", (0, 2), (1, 2)), # line payment_mode - ("ALIGN", (0, 2), (1, 2), "LEFT"), # line payment_mode - ("ALIGN", (-2, -1), (-1, -1), "LEFT"), - ("BOX", (0, 0), (-1, -1), 0.25, colors.black), - ] - ) - ) - - signature = [] - signature += [[_("Signature:")]] - - tSig = Table(signature, colWidths=[(width - 90 * 2)], rowHeights=[80]) - tSig.setStyle( - TableStyle( - [ - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("BOX", (0, 0), (-1, -1), 0.25, colors.black), - ] - ) - ) - - w, h = tSig.wrapOn(p, 0, 0) - tSig.drawOn(p, 90, 200) - - w, h = t.wrapOn(p, 0, 0) - - t.drawOn(p, 90, 350) - - p.drawCentredString(10.5 * cm, 2 * cm, club_name) - p.drawCentredString(10.5 * cm, 1 * cm, club_address) - - p.showPage() - p.save() - return response - - -class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView): - """Display a statement sorted by labels.""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - template_name = "accounting/journal_statement_nature.jinja" - current_tab = "nature_statement" - - def statement(self, queryset, movement_type): - ret = collections.OrderedDict() - statement = collections.OrderedDict() - total_sum = 0 - for sat in [ - None, - *list(SimplifiedAccountingType.objects.order_by("label")), - ]: - amount = queryset.filter( - accounting_type__movement_type=movement_type, simpleaccounting_type=sat - ).aggregate(amount_sum=Sum("amount"))["amount_sum"] - label = sat.label if sat is not None else "" - if amount: - total_sum += amount - statement[label] = amount - ret[movement_type] = statement - ret[movement_type + "_sum"] = total_sum - return ret - - def big_statement(self): - label_list = ( - self.object.operations.order_by("label").values_list("label").distinct() - ) - labels = Label.objects.filter(id__in=label_list).all() - statement = collections.OrderedDict() - gen_statement = collections.OrderedDict() - no_label_statement = collections.OrderedDict() - gen_statement.update(self.statement(self.object.operations.all(), "CREDIT")) - gen_statement.update(self.statement(self.object.operations.all(), "DEBIT")) - statement[_("General statement")] = gen_statement - no_label_statement.update( - self.statement(self.object.operations.filter(label=None).all(), "CREDIT") - ) - no_label_statement.update( - self.statement(self.object.operations.filter(label=None).all(), "DEBIT") - ) - statement[_("No label operations")] = no_label_statement - for label in labels: - l_stmt = collections.OrderedDict() - journals = self.object.operations.filter(label=label).all() - l_stmt.update(self.statement(journals, "CREDIT")) - l_stmt.update(self.statement(journals, "DEBIT")) - statement[label] = l_stmt - return statement - - def get_context_data(self, **kwargs): - """Add infos to the context.""" - kwargs = super().get_context_data(**kwargs) - kwargs["statement"] = self.big_statement() - return kwargs - - -class JournalPersonStatementView(JournalTabsMixin, CanViewMixin, DetailView): - """Calculate a dictionary with operation target and sum of operations.""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - template_name = "accounting/journal_statement_person.jinja" - current_tab = "person_statement" - - def sum_by_target(self, target_id, target_type, movement_type): - return self.object.operations.filter( - accounting_type__movement_type=movement_type, - target_id=target_id, - target_type=target_type, - ).aggregate(amount_sum=Sum("amount"))["amount_sum"] - - def statement(self, movement_type): - statement = collections.OrderedDict() - for op in ( - self.object.operations.filter(accounting_type__movement_type=movement_type) - .order_by("target_type", "target_id") - .distinct() - ): - statement[op.target] = self.sum_by_target( - op.target_id, op.target_type, movement_type - ) - return statement - - def total(self, movement_type): - return sum(self.statement(movement_type).values()) - - def get_context_data(self, **kwargs): - """Add journal to the context.""" - kwargs = super().get_context_data(**kwargs) - kwargs["credit_statement"] = self.statement("CREDIT") - kwargs["debit_statement"] = self.statement("DEBIT") - kwargs["total_credit"] = self.total("CREDIT") - kwargs["total_debit"] = self.total("DEBIT") - return kwargs - - -class JournalAccountingStatementView(JournalTabsMixin, CanViewMixin, DetailView): - """Calculate a dictionary with operation type and sum of operations.""" - - model = GeneralJournal - pk_url_kwarg = "j_id" - template_name = "accounting/journal_statement_accounting.jinja" - current_tab = "accounting_statement" - - def statement(self): - statement = collections.OrderedDict() - for at in AccountingType.objects.order_by("code").all(): - sum_by_type = self.object.operations.filter( - accounting_type__code__startswith=at.code - ).aggregate(amount_sum=Sum("amount"))["amount_sum"] - if sum_by_type: - statement[at] = sum_by_type - return statement - - def get_context_data(self, **kwargs): - """Add journal to the context.""" - kwargs = super().get_context_data(**kwargs) - kwargs["statement"] = self.statement() - return kwargs - - -# Company views - - -class CompanyListView(CanViewMixin, ListView): - model = Company - template_name = "accounting/co_list.jinja" - - -class CompanyCreateView(CanCreateMixin, CreateView): - """Create a company.""" - - model = Company - fields = ["name"] - template_name = "core/create.jinja" - success_url = reverse_lazy("accounting:co_list") - - -class CompanyEditView(CanCreateMixin, UpdateView): - """Edit a company.""" - - model = Company - pk_url_kwarg = "co_id" - fields = ["name"] - template_name = "core/edit.jinja" - success_url = reverse_lazy("accounting:co_list") - - -# Label views - - -class LabelListView(CanViewMixin, DetailView): - model = ClubAccount - pk_url_kwarg = "clubaccount_id" - template_name = "accounting/label_list.jinja" - - -class LabelCreateView( - CanCreateMixin, CreateView -): # FIXME we need to check the rights before creating the object - model = Label - form_class = modelform_factory( - Label, fields=["name", "club_account"], widgets={"club_account": HiddenInput} - ) - template_name = "core/create.jinja" - - def get_initial(self): - ret = super().get_initial() - if "parent" in self.request.GET: - obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first() - if obj is not None: - ret["club_account"] = obj.id - return ret - - -class LabelEditView(CanEditMixin, UpdateView): - model = Label - pk_url_kwarg = "label_id" - fields = ["name"] - template_name = "core/edit.jinja" - - -class LabelDeleteView(CanEditMixin, DeleteView): - model = Label - pk_url_kwarg = "label_id" - template_name = "core/delete_confirm.jinja" - - def get_success_url(self): - return self.object.get_absolute_url() - - -class CloseCustomerAccountForm(forms.Form): - user = forms.ModelChoiceField( - label=_("Refound this account"), - help_text=None, - required=True, - widget=AutoCompleteSelectUser, - queryset=User.objects.all(), - ) - - -class RefoundAccountView(FormView): - """Create a selling with the same amount than the current user money.""" - - template_name = "accounting/refound_account.jinja" - form_class = CloseCustomerAccountForm - - def permission(self, user): - if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): - return True - else: - raise PermissionDenied - - def dispatch(self, request, *arg, **kwargs): - res = super().dispatch(request, *arg, **kwargs) - if self.permission(request.user): - return res - - def post(self, request, *arg, **kwargs): - self.operator = request.user - if self.permission(request.user): - return super().post(self, request, *arg, **kwargs) - - def form_valid(self, form): - self.customer = form.cleaned_data["user"] - self.create_selling() - return super().form_valid(form) - - def get_success_url(self): - return reverse("accounting:refound_account") - - def create_selling(self): - with transaction.atomic(): - uprice = self.customer.customer.amount - refound_club_counter = Counter.objects.get( - id=settings.SITH_COUNTER_REFOUND_ID - ) - refound_club = refound_club_counter.club - s = Selling( - label=_("Refound account"), - unit_price=uprice, - quantity=1, - seller=self.operator, - customer=self.customer.customer, - club=refound_club, - counter=refound_club_counter, - product=Product.objects.get(id=settings.SITH_PRODUCT_REFOUND_ID), - ) - s.save() diff --git a/accounting/widgets/select.py b/accounting/widgets/select.py deleted file mode 100644 index 6b3145b7..00000000 --- a/accounting/widgets/select.py +++ /dev/null @@ -1,39 +0,0 @@ -from pydantic import TypeAdapter - -from accounting.models import ClubAccount, Company -from accounting.schemas import ClubAccountSchema, CompanySchema -from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple - -_js = ["bundled/accounting/components/ajax-select-index.ts"] - - -class AutoCompleteSelectClubAccount(AutoCompleteSelect): - component_name = "club-account-ajax-select" - model = ClubAccount - adapter = TypeAdapter(list[ClubAccountSchema]) - - js = _js - - -class AutoCompleteSelectMultipleClubAccount(AutoCompleteSelectMultiple): - component_name = "club-account-ajax-select" - model = ClubAccount - adapter = TypeAdapter(list[ClubAccountSchema]) - - js = _js - - -class AutoCompleteSelectCompany(AutoCompleteSelect): - component_name = "company-ajax-select" - model = Company - adapter = TypeAdapter(list[CompanySchema]) - - js = _js - - -class AutoCompleteSelectMultipleCompany(AutoCompleteSelectMultiple): - component_name = "company-ajax-select" - model = Company - adapter = TypeAdapter(list[CompanySchema]) - - js = _js diff --git a/club/admin.py b/club/admin.py index 04265245..0622cb16 100644 --- a/club/admin.py +++ b/club/admin.py @@ -19,8 +19,8 @@ from club.models import Club, Membership @admin.register(Club) class ClubAdmin(admin.ModelAdmin): - list_display = ("name", "unix_name", "parent", "is_active") - search_fields = ("name", "unix_name") + list_display = ("name", "slug_name", "parent", "is_active") + search_fields = ("name", "slug_name") autocomplete_fields = ( "parent", "board_group", diff --git a/club/forms.py b/club/forms.py index 76d13e2c..ab8f5757 100644 --- a/club/forms.py +++ b/club/forms.py @@ -29,18 +29,25 @@ from django.utils.translation import gettext_lazy as _ from club.models import Club, Mailing, MailingSubscription, Membership from core.models import User from core.views.forms import SelectDate, SelectDateTime -from core.views.widgets.select import AutoCompleteSelectMultipleUser +from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser from counter.models import Counter class ClubEditForm(forms.ModelForm): + error_css_class = "error" + required_css_class = "required" + class Meta: model = Club fields = ["address", "logo", "short_description"] + widgets = {"short_description": forms.Textarea()} - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["short_description"].widget = forms.Textarea() + +class ClubAdminEditForm(ClubEditForm): + admin_fields = ["name", "parent", "is_active"] + + class Meta(ClubEditForm.Meta): + fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields] class MailingForm(forms.Form): diff --git a/club/migrations/0011_auto_20180426_2013.py b/club/migrations/0011_auto_20180426_2013.py index c10fe7b7..1dba79c2 100644 --- a/club/migrations/0011_auto_20180426_2013.py +++ b/club/migrations/0011_auto_20180426_2013.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals import django.db.models.deletion +from django.conf import settings from django.db import migrations, models -import club.models - class Migration(migrations.Migration): dependencies = [("club", "0010_auto_20170912_2028")] @@ -15,7 +14,7 @@ class Migration(migrations.Migration): name="owner_group", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - default=club.models.get_default_owner_group, + default=lambda: settings.SITH_ROOT_USER_ID, related_name="owned_club", to="core.Group", ), diff --git a/club/migrations/0014_alter_club_options_rename_unix_name_club_slug_name_and_more.py b/club/migrations/0014_alter_club_options_rename_unix_name_club_slug_name_and_more.py new file mode 100644 index 00000000..c6b4460f --- /dev/null +++ b/club/migrations/0014_alter_club_options_rename_unix_name_club_slug_name_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.17 on 2025-02-28 20:34 + +import django.db.models.deletion +from django.db import migrations, models + +import core.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0044_alter_userban_options"), + ("club", "0013_alter_club_board_group_alter_club_members_group_and_more"), + ] + + operations = [ + migrations.AlterModelOptions(name="club", options={"ordering": ["name"]}), + migrations.RenameField( + model_name="club", + old_name="unix_name", + new_name="slug_name", + ), + migrations.AlterField( + model_name="club", + name="name", + field=models.CharField(unique=True, max_length=64, verbose_name="name"), + ), + migrations.AlterField( + model_name="club", + name="slug_name", + field=models.SlugField( + editable=False, max_length=30, unique=True, verbose_name="slug name" + ), + ), + migrations.AlterField( + model_name="club", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="club", + name="logo", + field=core.fields.ResizedImageField( + blank=True, + force_format="WEBP", + height=200, + null=True, + upload_to="club_logos", + verbose_name="logo", + width=200, + ), + ), + migrations.AlterField( + model_name="club", + name="page", + field=models.OneToOneField( + blank=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="club", + to="core.page", + ), + ), + migrations.AlterField( + model_name="club", + name="short_description", + field=models.CharField( + blank=True, + default="", + help_text="A summary of what your club does. This will be displayed on the club list page.", + max_length=1000, + verbose_name="short description", + ), + ), + ] diff --git a/club/models.py b/club/models.py index 4184715a..e7e99cda 100644 --- a/club/models.py +++ b/club/models.py @@ -26,7 +26,6 @@ from __future__ import annotations from typing import Iterable, Self from django.conf import settings -from django.core import validators from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import RegexValidator, validate_email @@ -35,48 +34,43 @@ from django.db.models import Exists, F, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property +from django.utils.text import slugify from django.utils.timezone import localdate from django.utils.translation import gettext_lazy as _ +from core.fields import ResizedImageField from core.models import Group, Notification, Page, SithFile, User -# Create your models here. - - -# This function prevents generating migration upon settings change -def get_default_owner_group(): - return settings.SITH_GROUP_ROOT_ID - class Club(models.Model): """The Club class, made as a tree to allow nice tidy organization.""" - id = models.AutoField(primary_key=True, db_index=True) - name = models.CharField(_("name"), max_length=64) + name = models.CharField(_("name"), unique=True, max_length=64) parent = models.ForeignKey( "Club", related_name="children", null=True, blank=True, on_delete=models.CASCADE ) - unix_name = models.CharField( - _("unix name"), - max_length=30, - unique=True, - validators=[ - validators.RegexValidator( - r"^[a-z0-9][a-z0-9._-]*[a-z0-9]$", - _( - "Enter a valid unix name. This value may contain only " - "letters, numbers ./-/_ characters." - ), - ) - ], - error_messages={"unique": _("A club with that unix name already exists.")}, + slug_name = models.SlugField( + _("slug name"), max_length=30, unique=True, editable=False ) - logo = models.ImageField( - upload_to="club_logos", verbose_name=_("logo"), null=True, blank=True + logo = ResizedImageField( + upload_to="club_logos", + verbose_name=_("logo"), + null=True, + blank=True, + force_format="WEBP", + height=200, + width=200, ) is_active = models.BooleanField(_("is active"), default=True) short_description = models.CharField( - _("short description"), max_length=1000, default="", blank=True, null=True + _("short description"), + max_length=1000, + default="", + blank=True, + help_text=_( + "A summary of what your club does. " + "This will be displayed on the club list page." + ), ) address = models.CharField(_("address"), max_length=254) home = models.OneToOneField( @@ -88,7 +82,7 @@ class Club(models.Model): on_delete=models.SET_NULL, ) page = models.OneToOneField( - Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE + Page, related_name="club", blank=True, on_delete=models.CASCADE ) members_group = models.OneToOneField( Group, related_name="club", on_delete=models.PROTECT @@ -98,7 +92,7 @@ class Club(models.Model): ) class Meta: - ordering = ["name", "unix_name"] + ordering = ["name"] def __str__(self): return self.name @@ -106,10 +100,12 @@ class Club(models.Model): @transaction.atomic() def save(self, *args, **kwargs): creation = self._state.adding + if (slug := slugify(self.name)[:30]) != self.slug_name: + self.slug_name = slug if not creation: db_club = Club.objects.get(id=self.id) - if self.unix_name != db_club.unix_name: - self.home.name = self.unix_name + if self.name != db_club.name: + self.home.name = self.slug_name self.home.save() if self.name != db_club.name: self.board_group.name = f"{self.name} - Bureau" @@ -123,11 +119,9 @@ class Club(models.Model): self.members_group = Group.objects.create( name=f"{self.name} - Membres", is_manually_manageable=False ) - super().save(*args, **kwargs) - if creation: self.make_home() self.make_page() - cache.set(f"sith_club_{self.unix_name}", self) + super().save(*args, **kwargs) def get_absolute_url(self): return reverse("club:club_view", kwargs={"club_id": self.id}) @@ -155,49 +149,37 @@ class Club(models.Model): def make_home(self) -> None: if self.home: return - home_root = SithFile.objects.filter(parent=None, name="clubs").first() - root = User.objects.filter(username="root").first() - if home_root and root: - home = SithFile(parent=home_root, name=self.unix_name, owner=root) - home.save() - self.home = home - self.save() + home_root = SithFile.objects.get(parent=None, name="clubs") + root = User.objects.get(id=settings.SITH_ROOT_USER_ID) + self.home = SithFile.objects.create( + parent=home_root, name=self.slug_name, owner=root + ) def make_page(self) -> None: - root = User.objects.filter(username="root").first() - if not self.page: - club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() - if root and club_root: - public = Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first() - p = Page(name=self.unix_name) - p.parent = club_root - p.save(force_lock=True) - if public: - p.view_groups.add(public) - p.save(force_lock=True) - if self.parent and self.parent.page: - p.parent = self.parent.page - self.page = p - self.save() - elif self.page and self.page.name != self.unix_name: - self.page.unset_lock() - self.page.name = self.unix_name - self.page.save(force_lock=True) - elif ( - self.page - and self.parent - and self.parent.page - and self.page.parent != self.parent.page - ): - self.page.unset_lock() + page_name = self.slug_name + if not self.page_id: + # Club.page is a OneToOneField, so if we are inside this condition + # then self._meta.state.adding is True. + club_root = Page.objects.get(name=settings.SITH_CLUB_ROOT_PAGE) + public = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) + p = Page(name=page_name, parent=club_root) + p.save(force_lock=True) + p.view_groups.add(public) + if self.parent and self.parent.page_id: + p.parent_id = self.parent.page_id + self.page = p + return + self.page.unset_lock() + if self.page.name != page_name: + self.page.name = page_name + elif self.parent and self.parent.page and self.page.parent != self.parent.page: self.page.parent = self.parent.page - self.page.save(force_lock=True) + self.page.save(force_lock=True) def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # Invalidate the cache of this club and of its memberships for membership in self.members.ongoing().select_related("user"): cache.delete(f"membership_{self.id}_{membership.user.id}") - cache.delete(f"sith_club_{self.unix_name}") self.board_group.delete() self.members_group.delete() return super().delete(*args, **kwargs) diff --git a/club/templates/club/club_detail.jinja b/club/templates/club/club_detail.jinja index 42e93b81..2d2c5719 100644 --- a/club/templates/club/club_detail.jinja +++ b/club/templates/club/club_detail.jinja @@ -4,7 +4,7 @@ {% block content %}
{% if club.logo %} - + {% endif %} {% if page_revision %} {{ page_revision|markdown }} diff --git a/club/templates/club/club_tools.jinja b/club/templates/club/club_tools.jinja index fa9584fa..9ca46f81 100644 --- a/club/templates/club/club_tools.jinja +++ b/club/templates/club/club_tools.jinja @@ -16,7 +16,7 @@

{% trans %}Counters:{% endtrans %}

    - {% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %} + {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %} {% for l in Launderette.objects.all() %}
  • {{ l }}
  • {% endfor %} @@ -29,15 +29,7 @@ {% endfor %} {% endif %}
- {% if object.club_account.exists() %} -

{% trans %}Accounting: {% endtrans %}

- - {% endif %} - {% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %} + {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
  • {% trans %}Manage launderettes{% endtrans %}
  • {% endif %}
    diff --git a/club/templates/club/edit_club.jinja b/club/templates/club/edit_club.jinja new file mode 100644 index 00000000..d70e2140 --- /dev/null +++ b/club/templates/club/edit_club.jinja @@ -0,0 +1,54 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans name=object %}Edit {{ name }}{% endtrans %} +{% endblock %} + +{% block content %} +

    {% trans name=object %}Edit {{ name }}{% endtrans %}

    + + + {% csrf_token %} + + {{ form.non_field_errors() }} + + {% if form.admin_fields %} + {# If the user is admin, display the admin fields, + and explicitly separate them from the non-admin ones, + with some help text. + Non-admin users will only see the regular form fields, + so they don't need thoses explanations #} +

    {% trans %}Club properties{% endtrans %}

    +

    + {% trans trimmed %} + The following form fields are linked to the core properties of a club. + Only admin users can see and edit them. + {% endtrans %} +

    +
    + {% for field_name in form.admin_fields %} + {% set field = form[field_name] %} +
    + {{ field.errors }} + {{ field.label_tag() }} + {{ field }} +
    + {# Remove the the admin fields from the form. + The remaining non-admin fields will be rendered + at once with a simple {{ form.as_p() }} #} + {% set _ = form.fields.pop(field_name) %} + {% endfor %} +
    + +

    {% trans %}Club informations{% endtrans %}

    +

    + {% trans trimmed %} + The following form fields are linked to the basic description of a club. + All board members of this club can see and edit them. + {% endtrans %} +

    + {% endif %} + {{ form.as_p() }} +

    + +{% endblock content %} diff --git a/club/templates/club/stats.jinja b/club/templates/club/stats.jinja deleted file mode 100644 index 43488be0..00000000 --- a/club/templates/club/stats.jinja +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Club stats{% endtrans %} -{% endblock %} - -{% block content %} - {% if club_list %} -

    {% trans %}Club stats{% endtrans %}

    -
    - {% csrf_token %} -

    - -

    -

    -
    - - - - - - - - - - {% for c in club_list.order_by('id') %} - {% set members = c.members.all() %} - {% if request.GET['branch'] %} - {% set members = members.filter(user__department=request.GET['branch']) %} - {% endif %} - - - - - - {% endfor %} - -
    ClubMember numberOld member number
    {{ c.get_display_name() }}{{ members.filter(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}{{ members.exclude(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}
    - {% else %} - {% trans %}There is no club in this website.{% endtrans %} - {% endif %} -{% endblock %} - - - diff --git a/club/tests.py b/club/tests.py index a9b7e2e6..217f29f6 100644 --- a/club/tests.py +++ b/club/tests.py @@ -14,20 +14,21 @@ # from datetime import timedelta +import pytest from django.conf import settings from django.core.cache import cache -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone from django.utils.timezone import localdate, localtime, now from django.utils.translation import gettext as _ from model_bakery import baker +from pytest_django.asserts import assertRedirects from club.forms import MailingForm from club.models import Club, Mailing, Membership from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User -from sith.settings import SITH_BAR_MANAGER, SITH_MAIN_CLUB_ID class TestClub(TestCase): @@ -64,12 +65,8 @@ class TestClub(TestCase): # not subscribed cls.public = User.objects.get(username="public") - cls.ae = Club.objects.filter(pk=SITH_MAIN_CLUB_ID)[0] - cls.club = Club.objects.create( - name="Fake Club", - unix_name="fake-club", - address="5 rue de la République, 90000 Belfort", - ) + cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) + cls.club = baker.make(Club) cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) a_month_ago = now() - timedelta(days=30) yesterday = now() - timedelta(days=1) @@ -265,7 +262,7 @@ class TestClubModel(TestClub): for membership in memberships.select_related("user"): user = membership.user expected_html += ( - f"" + f'' f"{user.get_display_name()}" f"{settings.SITH_CLUB_ROLES[membership.role]}" f"{membership.description}" @@ -579,13 +576,11 @@ class TestMailingForm(TestCase): cls.krophil = User.objects.get(username="krophil") cls.comunity = User.objects.get(username="comunity") cls.root = User.objects.get(username="root") - cls.bdf = Club.objects.get(unix_name=SITH_BAR_MANAGER["unix_name"]) - cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.bdf.id}) - - def setUp(self): + cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID) + cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id}) Membership( - user=self.rbatsbak, - club=self.bdf, + user=cls.rbatsbak, + club=cls.club, start_date=timezone.now(), role=settings.SITH_CLUB_ROLES_ID["Board member"], ).save() @@ -894,13 +889,43 @@ class TestClubSellingView(TestCase): @classmethod def setUpTestData(cls): - cls.ae = Club.objects.get(unix_name="ae") - cls.skia = User.objects.get(username="skia") + cls.club = baker.make(Club) + cls.admin = baker.make(User, is_superuser=True) def test_page_not_internal_error(self): """Test that the page does not return and internal error.""" - self.client.force_login(self.skia) + self.client.force_login(self.admin) response = self.client.get( - reverse("club:club_sellings", kwargs={"club_id": self.ae.id}) + reverse("club:club_sellings", kwargs={"club_id": self.club.id}) ) assert response.status_code == 200 + + +@pytest.mark.django_db +def test_club_board_member_cannot_edit_club_properties(client: Client): + user = subscriber_user.make() + club = baker.make(Club, name="old name", is_active=True, address="old address") + baker.make(Membership, club=club, user=user, role=7) + client.force_login(user) + res = client.post( + reverse("club:club_edit", kwargs={"club_id": club.id}), + {"name": "new name", "is_active": False, "address": "new address"}, + ) + # The request should success, + # but admin-only fields shouldn't be taken into account + assertRedirects(res, club.get_absolute_url()) + club.refresh_from_db() + assert club.name == "old name" + assert club.is_active + assert club.address == "new address" + + +@pytest.mark.django_db +def test_edit_club_page_doesnt_crash(client: Client): + """crash test for club:club_edit""" + club = baker.make(Club) + user = subscriber_user.make() + baker.make(Membership, club=club, user=user, role=3) + client.force_login(user) + res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id})) + assert res.status_code == 200 diff --git a/club/urls.py b/club/urls.py index 1f82a4d8..664d93a6 100644 --- a/club/urls.py +++ b/club/urls.py @@ -26,7 +26,6 @@ from django.urls import path from club.views import ( ClubCreateView, - ClubEditPropView, ClubEditView, ClubListView, ClubMailingView, @@ -37,7 +36,6 @@ from club.views import ( ClubRevView, ClubSellingCSVView, ClubSellingView, - ClubStatView, ClubToolsView, ClubView, MailingAutoGenerationView, @@ -54,7 +52,6 @@ from club.views import ( urlpatterns = [ path("", ClubListView.as_view(), name="club_list"), path("new/", ClubCreateView.as_view(), name="club_new"), - path("stats/", ClubStatView.as_view(), name="club_stats"), path("/", ClubView.as_view(), name="club_view"), path( "/rev//", ClubRevView.as_view(), name="club_view_rev" @@ -72,7 +69,6 @@ urlpatterns = [ path( "/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" ), - path("/prop/", ClubEditPropView.as_view(), name="club_prop"), path("/tools/", ClubToolsView.as_view(), name="tools"), path("/mailing/", ClubMailingView.as_view(), name="mailing"), path( diff --git a/club/views.py b/club/views.py index 767f5788..0c32cfad 100644 --- a/club/views.py +++ b/club/views.py @@ -39,10 +39,16 @@ from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.translation import gettext as _t from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, ListView, TemplateView, View +from django.views.generic import DetailView, ListView, View from django.views.generic.edit import CreateView, DeleteView, UpdateView -from club.forms import ClubEditForm, ClubMemberForm, MailingForm, SellingsForm +from club.forms import ( + ClubAdminEditForm, + ClubEditForm, + ClubMemberForm, + MailingForm, + SellingsForm, +) from club.models import Club, Mailing, MailingSubscription, Membership from com.views import ( PosterCreateBaseView, @@ -50,12 +56,7 @@ from com.views import ( PosterEditBaseView, PosterListBaseView, ) -from core.auth.mixins import ( - CanCreateMixin, - CanEditMixin, - CanEditPropMixin, - CanViewMixin, -) +from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.models import PageRev from core.views import DetailFormView, PageEditViewBase from core.views.mixins import TabedViewMixin @@ -78,23 +79,23 @@ class ClubTabsMixin(TabedViewMixin): } ] if self.request.user.can_view(self.object): - tab_list.append( - { - "url": reverse( - "club:club_members", kwargs={"club_id": self.object.id} - ), - "slug": "members", - "name": _("Members"), - } - ) - tab_list.append( - { - "url": reverse( - "club:club_old_members", kwargs={"club_id": self.object.id} - ), - "slug": "elderlies", - "name": _("Old members"), - } + tab_list.extend( + [ + { + "url": reverse( + "club:club_members", kwargs={"club_id": self.object.id} + ), + "slug": "members", + "name": _("Members"), + }, + { + "url": reverse( + "club:club_old_members", kwargs={"club_id": self.object.id} + ), + "slug": "elderlies", + "name": _("Old members"), + }, + ] ) if self.object.page: tab_list.append( @@ -107,21 +108,23 @@ class ClubTabsMixin(TabedViewMixin): } ) if self.request.user.can_edit(self.object): - tab_list.append( - { - "url": reverse("club:tools", kwargs={"club_id": self.object.id}), - "slug": "tools", - "name": _("Tools"), - } - ) - tab_list.append( - { - "url": reverse( - "club:club_edit", kwargs={"club_id": self.object.id} - ), - "slug": "edit", - "name": _("Edit"), - } + tab_list.extend( + [ + { + "url": reverse( + "club:tools", kwargs={"club_id": self.object.id} + ), + "slug": "tools", + "name": _("Tools"), + }, + { + "url": reverse( + "club:club_edit", kwargs={"club_id": self.object.id} + ), + "slug": "edit", + "name": _("Edit"), + }, + ] ) if self.object.page and self.request.user.can_edit(self.object.page): tab_list.append( @@ -134,40 +137,30 @@ class ClubTabsMixin(TabedViewMixin): "name": _("Edit club page"), } ) - tab_list.append( - { - "url": reverse( - "club:club_sellings", kwargs={"club_id": self.object.id} - ), - "slug": "sellings", - "name": _("Sellings"), - } - ) - tab_list.append( - { - "url": reverse("club:mailing", kwargs={"club_id": self.object.id}), - "slug": "mailing", - "name": _("Mailing list"), - } - ) - tab_list.append( - { - "url": reverse( - "club:poster_list", kwargs={"club_id": self.object.id} - ), - "slug": "posters", - "name": _("Posters list"), - } - ) - if self.request.user.is_owner(self.object): - tab_list.append( - { - "url": reverse( - "club:club_prop", kwargs={"club_id": self.object.id} - ), - "slug": "props", - "name": _("Props"), - } + tab_list.extend( + [ + { + "url": reverse( + "club:club_sellings", kwargs={"club_id": self.object.id} + ), + "slug": "sellings", + "name": _("Sellings"), + }, + { + "url": reverse( + "club:mailing", kwargs={"club_id": self.object.id} + ), + "slug": "mailing", + "name": _("Mailing list"), + }, + { + "url": reverse( + "club:poster_list", kwargs={"club_id": self.object.id} + ), + "slug": "posters", + "name": _("Posters list"), + }, + ] ) return tab_list @@ -189,8 +182,11 @@ class ClubView(ClubTabsMixin, DetailView): def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - if self.object.page and self.object.page.revisions.exists(): - kwargs["page_revision"] = self.object.page.revisions.last().content + kwargs["page_revision"] = ( + PageRev.objects.filter(page_id=self.object.page_id) + .order_by("-date") + .first() + ) return kwargs @@ -452,23 +448,23 @@ class ClubSellingCSVView(ClubSellingView): class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView): - """Edit a Club's main informations (for the club's members).""" + """Edit a Club. + + Regular club board members will be able to edit the main infos + (like the logo and the description). + Admins will also be able to edit the club properties + (like the name and the parent club). + """ model = Club pk_url_kwarg = "club_id" - form_class = ClubEditForm - template_name = "core/edit.jinja" + template_name = "club/edit_club.jinja" current_tab = "edit" - -class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView): - """Edit the properties of a Club object (for the Sith admins).""" - - model = Club - pk_url_kwarg = "club_id" - fields = ["name", "unix_name", "parent", "is_active"] - template_name = "core/edit.jinja" - current_tab = "props" + def get_form_class(self): + if self.object.is_owned_by(self.request.user): + return ClubAdminEditForm + return ClubEditForm class ClubCreateView(PermissionRequiredMixin, CreateView): @@ -476,8 +472,8 @@ class ClubCreateView(PermissionRequiredMixin, CreateView): model = Club pk_url_kwarg = "club_id" - fields = ["name", "unix_name", "parent"] - template_name = "core/edit.jinja" + fields = ["name", "parent"] + template_name = "core/create.jinja" permission_required = "club.add_club" @@ -522,15 +518,6 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView): return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) -class ClubStatView(TemplateView): - template_name = "club/stats.jinja" - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["club_list"] = Club.objects.all() - return kwargs - - class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): """A list of mailing for a given club.""" @@ -542,26 +529,19 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs["club_id"] = self.get_object().id + kwargs["club_id"] = self.object.id kwargs["user_id"] = self.request.user.id - kwargs["mailings"] = self.mailings + kwargs["mailings"] = self.object.mailings.all() return kwargs - def dispatch(self, request, *args, **kwargs): - self.mailings = Mailing.objects.filter(club_id=self.get_object().id).all() - return super().dispatch(request, *args, **kwargs) - def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - kwargs["club"] = self.get_object() + mailings = list(self.object.mailings.all()) + kwargs["club"] = self.object kwargs["user"] = self.request.user - kwargs["mailings"] = self.mailings - kwargs["mailings_moderated"] = ( - kwargs["mailings"].exclude(is_moderated=False).all() - ) - kwargs["mailings_not_moderated"] = ( - kwargs["mailings"].exclude(is_moderated=True).all() - ) + kwargs["mailings"] = mailings + kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated] + kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated] kwargs["form_actions"] = { "NEW_MALING": self.form_class.ACTION_NEW_MAILING, "NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION, @@ -572,7 +552,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): def add_new_mailing(self, cleaned_data) -> ValidationError | None: """Create a new mailing list from the form.""" mailing = Mailing( - club=self.get_object(), + club=self.object, email=cleaned_data["mailing_email"], moderator=self.request.user, is_moderated=False, @@ -649,7 +629,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): return resp def get_success_url(self, **kwargs): - return reverse_lazy("club:mailing", kwargs={"club_id": self.get_object().id}) + return reverse("club:mailing", kwargs={"club_id": self.object.id}) class MailingDeleteView(CanEditMixin, DeleteView): diff --git a/club/widgets/select.py b/club/widgets/ajax_select.py similarity index 83% rename from club/widgets/select.py rename to club/widgets/ajax_select.py index d46bb344..36ad3e9a 100644 --- a/club/widgets/select.py +++ b/club/widgets/ajax_select.py @@ -2,7 +2,10 @@ from pydantic import TypeAdapter from club.models import Club from club.schemas import ClubSchema -from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple +from core.views.widgets.ajax_select import ( + AutoCompleteSelect, + AutoCompleteSelectMultiple, +) _js = ["bundled/club/components/ajax-select-index.ts"] diff --git a/com/api.py b/com/api.py index 99186f36..6de78a3c 100644 --- a/com/api.py +++ b/com/api.py @@ -9,7 +9,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema -from com.calendar import IcsCalendar +from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from com.schemas import NewsDateFilterSchema, NewsDateSchema from core.auth.api_permissions import HasPerm diff --git a/com/forms.py b/com/forms.py index 8b81a3f9..e94d697e 100644 --- a/com/forms.py +++ b/com/forms.py @@ -8,7 +8,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from club.models import Club -from club.widgets.select import AutoCompleteSelectClub +from club.widgets.ajax_select import AutoCompleteSelectClub from com.models import News, NewsDate, Poster from core.models import User from core.utils import get_end_of_semester diff --git a/com/calendar.py b/com/ics_calendar.py similarity index 100% rename from com/calendar.py rename to com/ics_calendar.py diff --git a/com/models.py b/com/models.py index 2b3a76c4..c7e66515 100644 --- a/com/models.py +++ b/com/models.py @@ -337,7 +337,7 @@ class Screen(models.Model): def active_posters(self): now = timezone.now() - return self.posters.filter(d=True, date_begin__lte=now).filter( + return self.posters.filter(is_moderated=True, date_begin__lte=now).filter( Q(date_end__isnull=True) | Q(date_end__gte=now) ) diff --git a/com/schemas.py b/com/schemas.py index 93ee5315..3933daa1 100644 --- a/com/schemas.py +++ b/com/schemas.py @@ -2,7 +2,7 @@ from datetime import datetime from ninja import FilterSchema, ModelSchema from ninja_extra import service_resolver -from ninja_extra.controllers import RouteContext +from ninja_extra.context import RouteContext from pydantic import Field from club.schemas import ClubProfileSchema diff --git a/com/signals.py b/com/signals.py index 1c42c6e9..467159d2 100644 --- a/com/signals.py +++ b/com/signals.py @@ -1,7 +1,7 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from com.calendar import IcsCalendar +from com.ics_calendar import IcsCalendar from com.models import News diff --git a/com/tests/test_api.py b/com/tests/test_api.py index ba48f49c..7c3bcb7b 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -16,7 +16,7 @@ from django.utils.timezone import now from model_bakery import baker, seq from pytest_django.asserts import assertNumQueries -from com.calendar import IcsCalendar +from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from core.markdown import markdown from core.models import User diff --git a/com/tests/test_views.py b/com/tests/test_views.py index ce96766e..03d28adc 100644 --- a/com/tests/test_views.py +++ b/com/tests/test_views.py @@ -305,7 +305,7 @@ class TestNewsCreation(TestCase): # we will just test that the ICS is modified. # Checking that the ICS is *well* modified is up to the ICS tests - with patch("com.calendar.IcsCalendar.make_internal") as mocked: + with patch("com.ics_calendar.IcsCalendar.make_internal") as mocked: self.client.post(reverse("com:news_new"), self.valid_payload) mocked.assert_called() @@ -314,7 +314,7 @@ class TestNewsCreation(TestCase): self.valid_payload["occurrences"] = 2 last_news = News.objects.order_by("id").last() - with patch("com.calendar.IcsCalendar.make_internal") as mocked: + with patch("com.ics_calendar.IcsCalendar.make_internal") as mocked: self.client.post( reverse("com:news_edit", kwargs={"news_id": last_news.id}), self.valid_payload, diff --git a/com/views.py b/com/views.py index e1114e57..f6e12fd2 100644 --- a/com/views.py +++ b/com/views.py @@ -43,8 +43,8 @@ from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic.edit import CreateView, DeleteView, UpdateView from club.models import Club, Mailing -from com.calendar import IcsCalendar from com.forms import NewsDateForm, NewsForm, PosterForm +from com.ics_calendar import IcsCalendar from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from core.auth.mixins import ( CanEditPropMixin, diff --git a/core/auth/mixins.py b/core/auth/mixins.py index 974e9bd1..54bdc481 100644 --- a/core/auth/mixins.py +++ b/core/auth/mixins.py @@ -169,10 +169,9 @@ class CanCreateMixin(View): super().__init__(*args, **kwargs) def dispatch(self, request, *arg, **kwargs): - res = super().dispatch(request, *arg, **kwargs) if not request.user.is_authenticated: raise PermissionDenied - return res + return super().dispatch(request, *arg, **kwargs) def form_valid(self, form): obj = form.instance diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 21fde2e5..f7843989 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -36,21 +36,12 @@ from django.utils import timezone from django.utils.timezone import localdate from PIL import Image -from accounting.models import ( - AccountingType, - BankAccount, - ClubAccount, - Company, - GeneralJournal, - Operation, - SimplifiedAccountingType, -) from club.models import Club, Membership -from com.calendar import IcsCalendar +from com.ics_calendar import IcsCalendar from com.models import News, NewsDate, Sith, Weekmail from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.utils import resize_image -from counter.models import Counter, Product, ProductType, StudentCard +from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard from election.models import Candidature, Election, ElectionList, Role from forum.models import Forum from pedagogy.models import UV @@ -120,10 +111,7 @@ class Command(BaseCommand): club_root = SithFile.objects.create(name="clubs", owner=root) sas = SithFile.objects.create(name="SAS", owner=root) main_club = Club.objects.create( - id=1, - name=settings.SITH_MAIN_CLUB["name"], - unix_name=settings.SITH_MAIN_CLUB["unix_name"], - address=settings.SITH_MAIN_CLUB["address"], + id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" ) main_club.board_group.permissions.add( *Permission.objects.filter( @@ -131,16 +119,14 @@ class Command(BaseCommand): ) ) bar_club = Club.objects.create( - id=2, - name=settings.SITH_BAR_MANAGER["name"], - unix_name=settings.SITH_BAR_MANAGER["unix_name"], - address=settings.SITH_BAR_MANAGER["address"], + id=settings.SITH_PDF_CLUB_ID, + name="PdF", + address="6 Boulevard Anatole France, 90000 Belfort", ) Club.objects.create( - id=84, - name=settings.SITH_LAUNDERETTE_MANAGER["name"], - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"], - address=settings.SITH_LAUNDERETTE_MANAGER["address"], + id=settings.SITH_LAUNDERETTE_CLUB_ID, + name="Laverie", + address="6 Boulevard Anatole France, 90000 Belfort", ) self.reset_index("club") @@ -353,31 +339,17 @@ Welcome to the wiki page! # Clubs Club.objects.create( - name="Bibo'UT", - unix_name="bibout", - address="46 de la Boustifaille", - parent=main_club, + name="Bibo'UT", address="46 de la Boustifaille", parent=main_club ) guyut = Club.objects.create( - name="Guy'UT", - unix_name="guyut", - address="42 de la Boustifaille", - parent=main_club, - ) - Club.objects.create( - name="Woenzel'UT", unix_name="woenzel", address="Woenzel", parent=guyut + name="Guy'UT", address="42 de la Boustifaille", parent=main_club ) + Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut) troll = Club.objects.create( - name="Troll Penché", - unix_name="troll", - address="Terre Du Milieu", - parent=main_club, + name="Troll Penché", address="Terre Du Milieu", parent=main_club ) refound = Club.objects.create( - name="Carte AE", - unix_name="carte_ae", - address="Jamais imprimée", - parent=main_club, + name="Carte AE", address="Jamais imprimée", parent=main_club ) Membership.objects.create(user=skia, club=main_club, role=3) @@ -470,7 +442,6 @@ Welcome to the wiki page! limit_age=18, ) cons = Product.objects.create( - id=settings.SITH_ECOCUP_CONS, name="Consigne Eco-cup", code="CONS", product_type=verre, @@ -480,7 +451,6 @@ Welcome to the wiki page! club=main_club, ) dcons = Product.objects.create( - id=settings.SITH_ECOCUP_DECO, name="Déconsigne Eco-cup", code="DECO", product_type=verre, @@ -509,6 +479,14 @@ Welcome to the wiki page! club=main_club, limit_age=18, ) + Product.objects.create( + name="remboursement", + code="REMBOURS", + purchase_price="0", + selling_price="0", + special_selling_price="0", + club=refound, + ) groups.subscribers.products.add( cotis, cotis2, refill, barb, cble, cors, carolus ) @@ -521,81 +499,10 @@ Welcome to the wiki page! eboutic.products.add(barb, cotis, cotis2, refill) Counter.objects.create(name="Carte AE", club=refound, type="OFFICE") - Product.objects.create( - name="remboursement", - code="REMBOURS", - purchase_price="0", - selling_price="0", - special_selling_price="0", - club=refound, - ) - # Accounting test values: - BankAccount.objects.create(name="AE TG", club=main_club) - BankAccount.objects.create(name="Carte AE", club=main_club) - ba = BankAccount.objects.create(name="AE TI", club=main_club) - ca = ClubAccount.objects.create( - name="Troll Penché", bank_account=ba, club=troll + ReturnableProduct.objects.create( + product=cons, returned_product=dcons, max_return=3 ) - gj = GeneralJournal.objects.create( - name="A16", start_date=date.today(), club_account=ca - ) - credit = AccountingType.objects.create( - code="74", label="Subventions d'exploitation", movement_type="CREDIT" - ) - debit = AccountingType.objects.create( - code="606", - label="Achats non stockés de matières et fournitures(*1)", - movement_type="DEBIT", - ) - debit2 = AccountingType.objects.create( - code="604", - label="Achats d'études et prestations de services(*2)", - movement_type="DEBIT", - ) - buying = AccountingType.objects.create( - code="60", label="Achats (sauf 603)", movement_type="DEBIT" - ) - comptes = AccountingType.objects.create( - code="6", label="Comptes de charge", movement_type="DEBIT" - ) - SimplifiedAccountingType.objects.create( - label="Je fais du simple 6", accounting_type=comptes - ) - woenzco = Company.objects.create(name="Woenzel & co") - - operation_list = [ - (27, "J'avais trop de bière", "CASH", buying, "USER", skia.id, None), - (4000, "Pas une opération", "CHECK", debit, "COMPANY", woenzco.id, 23), - (22, "C'est de l'argent ?", "CARD", credit, "CLUB", troll.id, None), - (37, "Je paye CASH", "CASH", debit2, "OTHER", None, None), - (300, "Paiement Guy", "CASH", buying, "USER", skia.id, None), - (32.3, "Essence", "CASH", buying, "OTHER", None, None), - (46.42, "Allumette", "CHECK", credit, "CLUB", main_club.id, 57), - (666.42, "Subvention club", "CASH", comptes, "CLUB", main_club.id, None), - (496, "Ça, c'est un 6", "CARD", comptes, "USER", skia.id, None), - (17, "La Gargotte du Korrigan", "CASH", debit2, "CLUB", bar_club.id, None), - ] - operations = [ - Operation( - number=index, - journal=gj, - date=localdate(), - amount=op[0], - remark=op[1], - mode=op[2], - done=True, - accounting_type=op[3], - target_type=op[4], - target_id=op[5], - target_label="" if op[4] != "OTHER" else "Autre source", - cheque_number=op[6], - ) - for index, op in enumerate(operation_list, start=1) - ] - for operation in operations: - operation.clean() - Operation.objects.bulk_create(operations) # Add barman to counter Counter.sellers.through.objects.bulk_create( @@ -919,6 +826,7 @@ Welcome to the wiki page! "view_album", "view_peoplepicturerelation", "add_peoplepicturerelation", + "add_page", ] ) ) diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 7acec959..447b6b98 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -64,12 +64,12 @@ class Command(BaseCommand): ) ) self.make_club( - Club.objects.get(unix_name="ae"), + Club.objects.get(id=settings.SITH_MAIN_CLUB_ID), random.sample(subscribers_now, k=min(30, len(subscribers_now))), random.sample(old_subscribers, k=min(60, len(old_subscribers))), ) self.make_club( - Club.objects.get(unix_name="troll"), + Club.objects.get(name="Troll Penché"), random.sample(subscribers_now, k=min(20, len(subscribers_now))), random.sample(old_subscribers, k=min(80, len(old_subscribers))), ) @@ -235,7 +235,7 @@ class Command(BaseCommand): categories = list( ProductType.objects.filter(name__in=[c.name for c in categories]) ) - ae = Club.objects.get(unix_name="ae") + ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) other_clubs = random.sample(list(Club.objects.all()), k=3) groups = list( Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) diff --git a/core/models.py b/core/models.py index 4748f311..7f42e83d 100644 --- a/core/models.py +++ b/core/models.py @@ -421,13 +421,9 @@ class User(AbstractUser): def is_launderette_manager(self): from club.models import Club - return ( - Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ) - .first() - .get_membership_for(self) - ) + return Club.objects.get( + id=settings.SITH_LAUNDERETTE_CLUB_ID + ).get_membership_for(self) @cached_property def is_banned_alcohol(self) -> bool: @@ -880,11 +876,9 @@ class SithFile(models.Model): def save(self, *args, **kwargs): sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first() self.is_in_sas = sas in self.get_parent_list() or self == sas - copy_rights = False - if self.id is None: - copy_rights = True + adding = self._state.adding super().save(*args, **kwargs) - if copy_rights: + if adding: self.copy_rights() if self.is_in_sas: for user in User.objects.filter( @@ -1366,6 +1360,18 @@ class PageRev(models.Model): class Meta: ordering = ["date"] + def __getattribute__(self, attr): + if attr == "owner_group": + return self.page.owner_group + elif attr == "edit_groups": + return self.page.edit_groups + elif attr == "view_groups": + return self.page.view_groups + elif attr == "unset_lock": + return self.page.unset_lock + else: + return object.__getattribute__(self, attr) + def __str__(self): return str(self.__dict__) @@ -1379,18 +1385,6 @@ class PageRev(models.Model): def get_absolute_url(self): return reverse("core:page", kwargs={"page_name": self.page._full_name}) - def __getattribute__(self, attr): - if attr == "owner_group": - return self.page.owner_group - elif attr == "edit_groups": - return self.page.edit_groups - elif attr == "view_groups": - return self.page.view_groups - elif attr == "unset_lock": - return self.page.unset_lock - else: - return object.__getattribute__(self, attr) - def can_be_edited_by(self, user): return self.page.can_be_edited_by(user) diff --git a/core/static/bundled/country-flags-index.ts b/core/static/bundled/country-flags-index.ts new file mode 100644 index 00000000..1dc005c3 --- /dev/null +++ b/core/static/bundled/country-flags-index.ts @@ -0,0 +1,3 @@ +import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; + +polyfillCountryFlagEmojis(); diff --git a/core/static/bundled/utils/api.ts b/core/static/bundled/utils/api.ts index 5d72b3b6..95a86e1c 100644 --- a/core/static/bundled/utils/api.ts +++ b/core/static/bundled/utils/api.ts @@ -1,4 +1,4 @@ -import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; +import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch"; import { client } from "#openapi"; export interface PaginatedResponse { @@ -14,6 +14,7 @@ export interface PaginatedRequest { // biome-ignore lint/style/useNamingConvention: api is in snake_case page_size?: number; }; + url: string; } type PaginatedEndpoint = ( @@ -29,8 +30,8 @@ export const paginated = async ( endpoint: PaginatedEndpoint, options?: PaginatedRequest, ): Promise => { - const maxPerPage = 199; - const queryParams = options ?? {}; + const maxPerPage = 200; + const queryParams = options ?? ({} as PaginatedRequest); queryParams.query = queryParams.query ?? {}; queryParams.query.page_size = maxPerPage; queryParams.query.page = 1; @@ -53,7 +54,7 @@ export const paginated = async ( return results; }; -interface Request { +interface Request extends TDataShape { client?: Client; } diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss index c8e59098..941b32a5 100644 --- a/core/static/core/components/card.scss +++ b/core/static/core/components/card.scss @@ -55,6 +55,14 @@ width: 80%; } + .card-top-left { + position: absolute; + top: 10px; + right: 10px; + padding: 10px; + text-align: center; + } + .card-content { color: black; display: flex; diff --git a/core/static/core/header.scss b/core/static/core/header.scss index fd2cae8c..53b6887f 100644 --- a/core/static/core/header.scss +++ b/core/static/core/header.scss @@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d; color: $text-color; font-weight: normal; line-height: 1.3em; + font-family: "Twemoji Country Flags", sans-serif; &:hover { background-color: $background-color-hovered; @@ -250,21 +251,31 @@ $hovered-red-text-color: #ff4d4d; justify-content: flex-start; } - >a { + a, button { + font-size: 100%; + margin: 0; text-align: right; color: $text-color; &:hover { color: $hovered-text-color; } + } - &:last-child { - color: $red-text-color; + form#logout-form { + margin: 0; + display: inline; + } + #logout-form button { + color: $red-text-color; - &:hover { - color: $hovered-red-text-color; - } + &:hover { + color: $hovered-red-text-color; } + background: none; + border: none; + cursor: pointer; + padding: 0; } } } diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 6b037995..f064332a 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -464,7 +464,7 @@ body { flex-wrap: wrap; $col-gap: 1rem; - $row-gap: 0.5rem; + $row-gap: $col-gap / 3; &.gap { column-gap: $col-gap; diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index ef184fbf..41b13398 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -23,6 +23,7 @@ + @@ -83,18 +84,18 @@
    - {% block tabs %} + {%- block tabs -%} {% include "core/base/tabs.jinja" %} - {% endblock %} + {%- endblock -%} - {% block errors%} + {%- block errors -%} {% if error %} {{ error }} {% endif %} - {% endblock %} + {%- endblock -%} - {% block content %} - {% endblock %} + {%- block content -%} + {%- endblock -%}
    diff --git a/core/templates/core/base/header.jinja b/core/templates/core/base/header.jinja index 99cd4c4f..4454aedb 100644 --- a/core/templates/core/base/header.jinja +++ b/core/templates/core/base/header.jinja @@ -59,7 +59,10 @@ {% trans %}Delete confirmation{% endtrans %}
    {% csrf_token %} -

    {% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}

    +

    {% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}

    diff --git a/core/templates/core/page.jinja b/core/templates/core/page.jinja index 620839aa..862bb216 100644 --- a/core/templates/core/page.jinja +++ b/core/templates/core/page.jinja @@ -12,16 +12,15 @@ {% endif %} {% endblock %} -{% macro print_page_name(page) %} - {% if page %} +{%- macro print_page_name(page) -%} + {%- if page -%} {{ print_page_name(page.parent) }} > {{ page.get_display_name() }} - {% endif %} -{% endmacro %} + {%- endif -%} +{%- endmacro -%} {% block content %} {{ print_page_name(page) }} -
    {% if page %} diff --git a/core/templates/core/user_detail.jinja b/core/templates/core/user_detail.jinja index 5fceb126..771ead72 100644 --- a/core/templates/core/user_detail.jinja +++ b/core/templates/core/user_detail.jinja @@ -132,111 +132,104 @@
    - {% if - user == profile - or user.memberships.ongoing().exists() - or user.is_board_member - or user.is_in_group(name=settings.SITH_BAR_MANAGER_BOARD_GROUP) - %} - {# if the user is member of a club, he can view the subscription state #} -
    - {% if profile.is_subscribed %} - {% if user == profile or user.is_root or user.is_board_member %} -
    - {{ user_subscription(profile) }} -
    - {% endif %} - {% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %} -
    - {# Shows tokens bought by the user #} - {{ show_tokens(profile) }} - {# Shows slots took by the user #} - {{ show_slots(profile) }} -
    - {% endif %} - {% else %} -
    - {% trans %}Not subscribed{% endtrans %} - {% if user.is_board_member %} - - {% trans %}New subscription{% endtrans %} - + {% if user == profile or user.memberships.ongoing().exists() %} + {# if the user is member of a club, he can view the subscription state #} +
    + {% if profile.is_subscribed %} + {% if user == profile or user.is_root or user.is_board_member %} +
    + {{ user_subscription(profile) }} +
    {% endif %} + {% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %} +
    + {{ show_tokens(profile) }} + {{ show_slots(profile) }} +
    + {% endif %} + {% else %} +
    + {% trans %}Not subscribed{% endtrans %} + {% if user.is_board_member %} + + {% trans %}New subscription{% endtrans %} + + {% endif %} + {% endif %} +
    {% endif %} -
    -{% endif %} -
    -{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %} -
    -
    - - {% trans %}Subscription history{% endtrans %} - - - - -
    -
    - - - - - - - - - - {% for sub in profile.subscriptions.all() %} - - - - - - - {% endfor %} -
    {% trans %}Subscription start{% endtrans %}{% trans %}Subscription end{% endtrans %}{% trans %}Subscription type{% endtrans %}{% trans %}Payment method{% endtrans %}
    {{ sub.subscription_start }}{{ sub.subscription_end }}{{ sub.subscription_type }}{{ sub.get_payment_method_display() }}
    -
    -
    -
    -{% endif %} - -
    - {% if user.is_root or user.is_board_member %} - - {% csrf_token %} - {{ gift_form.label }} - {{ gift_form.user }} - - - {% if profile.gifts.exists() %} - {% set gifts = profile.gifts.order_by("-date")|list %} -
    +
    + {% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %}
    - {% trans %}Last given gift :{% endtrans %} {{ gifts[0] }} + {% trans %}Subscription history{% endtrans %}
    -
      - {% for gift in gifts %} -
    • {{ gift }} - - - -
    • + + + + + + + + + + {% for sub in profile.subscriptions.all() %} + + + + + + {% endfor %} - +
      {% trans %}Subscription start{% endtrans %}{% trans %}Subscription end{% endtrans %}{% trans %}Subscription type{% endtrans %}{% trans %}Payment method{% endtrans %}
      {{ sub.subscription_start }}{{ sub.subscription_end }}{{ sub.subscription_type }}{{ sub.get_payment_method_display() }}
    - {% else %} - {% trans %}No gift given yet{% endtrans %} +
    +
    {% endif %} + +
    + {% if user.is_root or user.is_board_member %} +
    + {% csrf_token %} + {{ gift_form.label }} + {{ gift_form.user }} + +
    + {% if profile.gifts.exists() %} + {% set gifts = profile.gifts.order_by("-date")|list %} +
    +
    +
    + + {% trans %}Last given gift :{% endtrans %} {{ gifts[0] }} + + + + +
    +
    +
      + {% for gift in gifts %} +
    • {{ gift }} + + + +
    • + {% endfor %} +
    +
    + {% else %} + {% trans %}No gift given yet{% endtrans %} + {% endif %} +
    + {% endif %}
    - {% endif %} -
    {% endblock %} diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index 4c9b9462..e3d09748 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -62,6 +62,11 @@ {% trans %}Product types management{% endtrans %} +
  • + + {% trans %}Returnable products management{% endtrans %} + +
  • {% trans %}Cash register summaries{% endtrans %} @@ -109,28 +114,8 @@

    {% trans %}Accounting{% endtrans %}

    {% endif %} diff --git a/core/tests/test_core.py b/core/tests/test_core.py index e6b37e5c..930e8819 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -18,7 +18,9 @@ from smtplib import SMTPException import freezegun import pytest +from bs4 import BeautifulSoup from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import Permission from django.core import mail from django.core.cache import cache from django.core.mail import EmailMessage @@ -223,17 +225,19 @@ def test_full_markdown_syntax(): class TestPageHandling(TestCase): @classmethod def setUpTestData(cls): - cls.root = User.objects.get(username="root") - cls.root_group = Group.objects.get(name="Root") + cls.group = baker.make( + Group, permissions=[Permission.objects.get(codename="add_page")] + ) + cls.user = baker.make(User, groups=[cls.group]) def setUp(self): - self.client.force_login(self.root) + self.client.force_login(self.user) 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}, + {"parent": "", "name": "guy", "owner_group": self.group.id}, ) self.assertRedirects( response, reverse("core:page", kwargs={"page_name": "guy"}) @@ -249,32 +253,38 @@ class TestPageHandling(TestCase): def test_create_child_page_ok(self): """Should create a page correctly.""" - # remove all other pages to make sure there is no side effect - Page.objects.all().delete() - self.client.post( - reverse("core:page_new"), - {"parent": "", "name": "guy", "owner_group": str(self.root_group.id)}, + parent = baker.prepare(Page) + parent.save(force_lock=True) + response = self.client.get( + reverse("core:page_new") + f"?page={parent._full_name}/new" ) - page = Page.objects.first() - self.client.post( + + assert response.status_code == 200 + # The name and parent inputs should be already filled + soup = BeautifulSoup(response.content.decode(), "lxml") + assert soup.find("input", {"name": "name"})["value"] == "new" + select = soup.find("autocomplete-select", {"name": "parent"}) + assert select.find("option", {"selected": True})["value"] == str(parent.id) + + response = self.client.post( reverse("core:page_new"), { - "parent": str(page.id), - "name": "bibou", - "owner_group": str(self.root_group.id), + "parent": str(parent.id), + "name": "new", + "owner_group": str(self.group.id), }, ) - response = self.client.get( - reverse("core:page", kwargs={"page_name": "guy/bibou"}) - ) + new_url = reverse("core:page", kwargs={"page_name": f"{parent._full_name}/new"}) + assertRedirects(response, new_url, fetch_redirect_response=False) + response = self.client.get(new_url) assert response.status_code == 200 - assert '' in str(response.content) + assert f'' in response.content.decode() def test_access_child_page_ok(self): """Should display a page correctly.""" - parent = Page(name="guy", owner_group=self.root_group) + parent = Page(name="guy", owner_group=self.group) parent.save(force_lock=True) - page = Page(name="bibou", owner_group=self.root_group, parent=parent) + page = Page(name="bibou", owner_group=self.group, parent=parent) page.save(force_lock=True) response = self.client.get( reverse("core:page", kwargs={"page_name": "guy/bibou"}) @@ -293,7 +303,8 @@ class TestPageHandling(TestCase): def test_create_page_markdown_safe(self): """Should format the markdown and escape html correctly.""" self.client.post( - reverse("core:page_new"), {"parent": "", "name": "guy", "owner_group": "1"} + reverse("core:page_new"), + {"parent": "", "name": "guy", "owner_group": self.group.id}, ) self.client.post( reverse("core:page_edit", kwargs={"page_name": "guy"}), diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 9b2209b3..133f26a5 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest from django.conf import settings +from django.contrib import auth from django.core.management import call_command from django.test import Client, TestCase from django.urls import reverse @@ -219,3 +220,12 @@ def test_user_update_groups(client: Client): manageable_groups[1], *hidden_groups[:3], } + + +@pytest.mark.django_db +def test_logout(client: Client): + user = baker.make(User) + client.force_login(user) + res = client.post(reverse("core:logout")) + assertRedirects(res, reverse("core:login")) + assert auth.get_user(client).is_anonymous diff --git a/core/views/__init__.py b/core/views/__init__.py index a53671d5..daf84693 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -65,6 +65,6 @@ class DetailFormView(FormView, BaseDetailView): # E402: putting those import at the top of the file would also be difficult from .files import * # noqa: F403 E402 from .group import * # noqa: F403 E402 +from .index import * # noqa: F403 E402 from .page import * # noqa: F403 E402 -from .site import * # noqa: F403 E402 from .user import * # noqa: F403 E402 diff --git a/core/views/files.py b/core/views/files.py index 04498d5c..714b505d 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -41,7 +41,7 @@ from core.auth.mixins import ( ) from core.models import Notification, SithFile, User from core.views.mixins import AllowFragment -from core.views.widgets.select import ( +from core.views.widgets.ajax_select import ( AutoCompleteSelectMultipleGroup, AutoCompleteSelectSithFile, AutoCompleteSelectUser, @@ -403,6 +403,7 @@ class FileModerationView(AllowFragment, ListView): model = SithFile template_name = "core/file_moderation.jinja" queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False) + ordering = "id" paginate_by = 100 def dispatch(self, request: HttpRequest, *args, **kwargs): diff --git a/core/views/forms.py b/core/views/forms.py index 381fc8a3..312d0819 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -50,7 +50,7 @@ from PIL import Image from antispam.forms import AntiSpamEmailField from core.models import Gift, Group, Page, SithFile, User from core.utils import resize_image -from core.views.widgets.select import ( +from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectGroup, AutoCompleteSelectMultipleGroup, diff --git a/core/views/group.py b/core/views/group.py index ba6b406d..dac5b395 100644 --- a/core/views/group.py +++ b/core/views/group.py @@ -30,7 +30,7 @@ from core.auth.mixins import CanEditMixin from core.models import Group, User from core.views import DetailFormView from core.views.forms import PermissionGroupsForm -from core.views.widgets.select import AutoCompleteSelectMultipleUser +from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser # Forms diff --git a/core/views/site.py b/core/views/index.py similarity index 100% rename from core/views/site.py rename to core/views/index.py diff --git a/core/views/mixins.py b/core/views/mixins.py index 9687f5d9..e5b445d6 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.views import View @@ -6,20 +8,24 @@ from django.views import View class TabedViewMixin(View): """Basic functions for displaying tabs in the template.""" + current_tab: ClassVar[str | None] = None + list_of_tabs: ClassVar[list | None] = None + tabs_title: ClassVar[str | None] = None + def get_tabs_title(self): - if hasattr(self, "tabs_title"): - return self.tabs_title - raise ImproperlyConfigured("tabs_title is required") + if not self.tabs_title: + raise ImproperlyConfigured("tabs_title is required") + return self.tabs_title def get_current_tab(self): - if hasattr(self, "current_tab"): - return self.current_tab - raise ImproperlyConfigured("current_tab is required") + if not self.current_tab: + raise ImproperlyConfigured("current_tab is required") + return self.current_tab def get_list_of_tabs(self): - if hasattr(self, "list_of_tabs"): - return self.list_of_tabs - raise ImproperlyConfigured("list_of_tabs is required") + if not self.list_of_tabs: + raise ImproperlyConfigured("list_of_tabs is required") + return self.list_of_tabs def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) diff --git a/core/views/page.py b/core/views/page.py index f4b04f9c..23898217 100644 --- a/core/views/page.py +++ b/core/views/page.py @@ -12,6 +12,7 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from django.contrib.auth.mixins import PermissionRequiredMixin # This file contains all the views that concern the page model from django.forms.models import modelform_factory @@ -22,7 +23,6 @@ from django.views.generic import DetailView, ListView from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.auth.mixins import ( - CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin, @@ -115,20 +115,22 @@ class PageRevView(CanViewMixin, DetailView): return context -class PageCreateView(CanCreateMixin, CreateView): +class PageCreateView(PermissionRequiredMixin, CreateView): model = Page form_class = PageForm template_name = "core/page_prop.jinja" + permission_required = "core.add_page" def get_initial(self): - init = {} - if "page" in self.request.GET: - page_name = self.request.GET["page"] - parent_name = "/".join(page_name.split("/")[:-1]) - parent = Page.get_page_by_full_name(parent_name) + init = super().get_initial() + if "page" not in self.request.GET: + return init + page_name = self.request.GET["page"].rsplit("/", maxsplit=1) + if len(page_name) == 2: + parent = Page.get_page_by_full_name(page_name[0]) if parent is not None: init["parent"] = parent.id - init["name"] = page_name.split("/")[-1] + init["name"] = page_name[-1] return init def get_context_data(self, **kwargs): diff --git a/core/views/user.py b/core/views/user.py index 8e7b092c..a9ce811f 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -28,7 +28,6 @@ from datetime import date, timedelta from operator import itemgetter from smtplib import SMTPException -from django.conf import settings from django.contrib.auth import login, views from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.mixins import LoginRequiredMixin @@ -65,7 +64,7 @@ from core.views.forms import ( UserProfileForm, ) from core.views.mixins import QuickNotifMixin, TabedViewMixin -from counter.models import Refilling, Selling +from counter.models import Counter, Refilling, Selling from eboutic.models import Invoice from subscription.models import Subscription from trombi.views import UserTrombiForm @@ -205,14 +204,6 @@ class UserTabsMixin(TabedViewMixin): "name": _("Pictures"), }, ] - if settings.SITH_ENABLE_GALAXY and self.request.user.was_subscribed: - tab_list.append( - { - "url": reverse("galaxy:user", kwargs={"user_id": user.id}), - "slug": "galaxy", - "name": _("Galaxy"), - } - ) if self.request.user == user: tab_list.append( {"url": reverse("core:user_tools"), "slug": "tools", "name": _("Tools")} @@ -251,17 +242,7 @@ class UserTabsMixin(TabedViewMixin): if ( hasattr(user, "customer") and user.customer - and ( - user == self.request.user - or self.request.user.is_in_group( - pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID - ) - or self.request.user.is_in_group( - name=settings.SITH_BAR_MANAGER["unix_name"] - + settings.SITH_BOARD_SUFFIX - ) - or self.request.user.is_root - ) + and (user == self.request.user or user.has_perm("counter.view_customer")) ): tab_list.append( { @@ -370,12 +351,7 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView): raise Http404 if not ( - profile == request.user - or request.user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - or request.user.is_in_group( - name=settings.SITH_BAR_MANAGER["unix_name"] + settings.SITH_BOARD_SUFFIX - ) - or request.user.is_root + profile == request.user or request.user.has_perm("counter.view_customer") ): raise PermissionDenied @@ -385,8 +361,6 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView): kwargs = super().get_context_data(**kwargs) from django.db.models import Sum - from counter.models import Counter - foyer = Counter.objects.filter(name="Foyer").first() mde = Counter.objects.filter(name="MDE").first() gommette = Counter.objects.filter(name="La Gommette").first() @@ -599,14 +573,9 @@ class UserAccountBase(UserTabsMixin, DetailView): current_tab = "account" queryset = User.objects.select_related("customer") - def dispatch(self, request, *arg, **kwargs): # Manually validates the rights - if ( - kwargs.get("user_id") == request.user.id - or request.user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - or request.user.is_in_group( - name=settings.SITH_BAR_MANAGER["unix_name"] + settings.SITH_BOARD_SUFFIX - ) - or request.user.is_root + def dispatch(self, request, *arg, **kwargs): + if kwargs.get("user_id") == request.user.id or request.user.has_perm( + "counter.view_customer" ): return super().dispatch(request, *arg, **kwargs) raise PermissionDenied diff --git a/core/views/widgets/select.py b/core/views/widgets/ajax_select.py similarity index 100% rename from core/views/widgets/select.py rename to core/views/widgets/ajax_select.py diff --git a/counter/admin.py b/counter/admin.py index 5dc795f2..10f04c8d 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -26,6 +26,7 @@ from counter.models import ( Product, ProductType, Refilling, + ReturnableProduct, Selling, ) @@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin): search_fields = ("name", "code") +@admin.register(ReturnableProduct) +class ReturnableProductAdmin(admin.ModelAdmin): + list_display = ("product", "returned_product", "max_return") + search_fields = ( + "product__name", + "product__code", + "returned_product__name", + "returned_product__code", + ) + autocomplete_fields = ("product", "returned_product") + + @admin.register(Customer) class CustomerAdmin(SearchModelAdmin): list_display = ("user", "account_id", "amount") diff --git a/counter/fields.py b/counter/fields.py new file mode 100644 index 00000000..a212059d --- /dev/null +++ b/counter/fields.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +from django.conf import settings +from django.db import models + + +class CurrencyField(models.DecimalField): + """Custom database field used for currency.""" + + def __init__(self, *args, **kwargs): + kwargs["max_digits"] = 12 + kwargs["decimal_places"] = 2 + super().__init__(*args, **kwargs) + + def to_python(self, value): + if value is None: + return None + return super().to_python(value).quantize(Decimal("0.01")) + + +if settings.TESTING: + from model_bakery import baker + + baker.generators.add( + CurrencyField, + lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2), + ) +else: # pragma: no cover + # baker is only used in tests, so we don't need coverage for this part + pass diff --git a/counter/forms.py b/counter/forms.py index 59762920..27dc74d7 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -2,9 +2,10 @@ from django import forms from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget -from club.widgets.select import AutoCompleteSelectClub +from club.widgets.ajax_select import AutoCompleteSelectClub +from core.models import User from core.views.forms import NFCTextInput, SelectDate, SelectDateTime -from core.views.widgets.select import ( +from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleUser, @@ -17,9 +18,10 @@ from counter.models import ( Eticket, Product, Refilling, + ReturnableProduct, StudentCard, ) -from counter.widgets.select import ( +from counter.widgets.ajax_select import ( AutoCompleteSelectMultipleCounter, AutoCompleteSelectMultipleProduct, AutoCompleteSelectProduct, @@ -213,6 +215,25 @@ class ProductEditForm(forms.ModelForm): return ret +class ReturnableProductForm(forms.ModelForm): + class Meta: + model = ReturnableProduct + fields = ["product", "returned_product", "max_return"] + widgets = { + "product": AutoCompleteSelectProduct(), + "returned_product": AutoCompleteSelectProduct(), + } + + def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT + instance: ReturnableProduct = super().save(commit=commit) + if commit: + # This is expensive, but we don't have a task queue to offload it. + # Hopefully, creations and updates of returnable products + # occur very rarely + instance.update_balances() + return instance + + class CashSummaryFormBase(forms.Form): begin_date = forms.DateTimeField( label=_("Begin date"), widget=SelectDateTime, required=False @@ -230,3 +251,13 @@ class EticketForm(forms.ModelForm): "product": AutoCompleteSelectProduct, "event_date": SelectDate, } + + +class CloseCustomerAccountForm(forms.Form): + user = forms.ModelChoiceField( + label=_("Refound this account"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), + ) diff --git a/counter/management/commands/dump_accounts.py b/counter/management/commands/dump_accounts.py index e8219103..b25cb93c 100644 --- a/counter/management/commands/dump_accounts.py +++ b/counter/management/commands/dump_accounts.py @@ -11,7 +11,7 @@ from django.utils.timezone import now from django.utils.translation import gettext as _ from core.models import User, UserQuerySet -from counter.models import AccountDump, Counter, Customer, Selling +from counter.models import AccountDump, Counter, Customer, Product, Selling class Command(BaseCommand): @@ -106,6 +106,7 @@ class Command(BaseCommand): raise ValueError("One or more accounts were not engaged in a dump process") counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID) seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID) + product = Product.objects.get(id=settings.SITH_PRODUCT_REFOUND_ID) sales = Selling.objects.bulk_create( [ Selling( @@ -113,7 +114,7 @@ class Command(BaseCommand): club=counter.club, counter=counter, seller=seller, - product=None, + product=product, customer=account, quantity=1, unit_price=account.amount, @@ -126,7 +127,7 @@ class Command(BaseCommand): sales.sort(key=attrgetter("customer_id")) # dumps and sales are linked to the same customers - # and or both ordered with the same key, so zipping them is valid + # and both ordered with the same key, so zipping them is valid for dump, sale in zip(pending_dumps, sales, strict=False): dump.dump_operation = sale AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"]) diff --git a/counter/migrations/0001_initial.py b/counter/migrations/0001_initial.py index 34381c89..0eb145da 100644 --- a/counter/migrations/0001_initial.py +++ b/counter/migrations/0001_initial.py @@ -4,7 +4,7 @@ import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -78,7 +78,7 @@ class Migration(migrations.Migration): ), ( "amount", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="amount" ), ), @@ -145,19 +145,19 @@ class Migration(migrations.Migration): ), ( "purchase_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="purchase price" ), ), ( "selling_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="selling price" ), ), ( "special_selling_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="special selling price", @@ -240,7 +240,7 @@ class Migration(migrations.Migration): ), ( "amount", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="amount" ), ), @@ -324,7 +324,7 @@ class Migration(migrations.Migration): ("label", models.CharField(max_length=64, verbose_name="label")), ( "unit_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="unit price" ), ), diff --git a/counter/migrations/0002_auto_20160826_1342.py b/counter/migrations/0002_auto_20160826_1342.py index 7655a480..a9709477 100644 --- a/counter/migrations/0002_auto_20160826_1342.py +++ b/counter/migrations/0002_auto_20160826_1342.py @@ -4,7 +4,7 @@ import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -67,7 +67,7 @@ class Migration(migrations.Migration): ), ( "value", - accounting.models.CurrencyField( + counter.fields.CurrencyField( max_digits=12, verbose_name="value", decimal_places=2 ), ), diff --git a/counter/migrations/0020_auto_20221215_1709.py b/counter/migrations/0020_auto_20221215_1709.py index 22db406b..4cf6a95d 100644 --- a/counter/migrations/0020_auto_20221215_1709.py +++ b/counter/migrations/0020_auto_20221215_1709.py @@ -2,7 +2,7 @@ from django.db import migrations -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="customer", name="amount", - field=accounting.models.CurrencyField( + field=counter.fields.CurrencyField( decimal_places=2, default=0, max_digits=12, verbose_name="amount" ), ), diff --git a/counter/migrations/0025_remove_product_parent_product_and_more.py b/counter/migrations/0025_remove_product_parent_product_and_more.py index 64a2129c..9a2d76a9 100644 --- a/counter/migrations/0025_remove_product_parent_product_and_more.py +++ b/counter/migrations/0025_remove_product_parent_product_and_more.py @@ -2,7 +2,7 @@ from django.db import migrations, models -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="product", name="purchase_price", - field=accounting.models.CurrencyField( + field=counter.fields.CurrencyField( decimal_places=2, help_text="Initial cost of purchasing the product", max_digits=12, @@ -28,7 +28,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="product", name="special_selling_price", - field=accounting.models.CurrencyField( + field=counter.fields.CurrencyField( decimal_places=2, help_text="Price for barmen during their permanence", max_digits=12, diff --git a/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py new file mode 100644 index 00000000..82378636 --- /dev/null +++ b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.17 on 2025-03-05 14:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("counter", "0029_alter_selling_label")] + + operations = [ + migrations.CreateModel( + name="ReturnableProduct", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "max_return", + models.PositiveSmallIntegerField( + default=0, + help_text=( + "The maximum number of items a customer can return " + "without having actually bought them." + ), + verbose_name="maximum returns", + ), + ), + ( + "product", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="cons", + to="counter.product", + verbose_name="returnable product", + ), + ), + ( + "returned_product", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="dcons", + to="counter.product", + verbose_name="returned product", + ), + ), + ], + options={ + "verbose_name": "returnable product", + "verbose_name_plural": "returnable products", + }, + ), + migrations.AddConstraint( + model_name="returnableproduct", + constraint=models.CheckConstraint( + check=models.Q( + ("product", models.F("returned_product")), _negated=True + ), + name="returnableproduct_product_different_from_returned", + violation_error_message="The returnable product cannot be the same as the returned one", + ), + ), + migrations.CreateModel( + name="ReturnableProductBalance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("balance", models.SmallIntegerField(blank=True, default=0)), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="return_balances", + to="counter.customer", + ), + ), + ( + "returnable", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="balances", + to="counter.returnableproduct", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="returnableproductbalance", + constraint=models.UniqueConstraint( + fields=("customer", "returnable"), + name="returnable_product_unique_type_per_customer", + ), + ), + migrations.RemoveField(model_name="customer", name="recorded_products"), + ] diff --git a/counter/models.py b/counter/models.py index 1467c9f4..ee6088d9 100644 --- a/counter/models.py +++ b/counter/models.py @@ -21,7 +21,7 @@ import string from datetime import date, datetime, timedelta from datetime import timezone as tz from decimal import Decimal -from typing import Self +from typing import Literal, Self from dict2xml import dict2xml from django.conf import settings @@ -38,13 +38,12 @@ from django_countries.fields import CountryField from ordered_model.models import OrderedModel from phonenumber_field.modelfields import PhoneNumberField -from accounting.models import CurrencyField from club.models import Club from core.fields import ResizedImageField from core.models import Group, Notification, User from core.utils import get_start_of_semester from counter.apps import PAYMENT_METHOD -from sith.settings import SITH_MAIN_CLUB +from counter.fields import CurrencyField from subscription.models import Subscription @@ -94,7 +93,6 @@ class Customer(models.Model): user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) account_id = models.CharField(_("account id"), max_length=10, unique=True) amount = CurrencyField(_("amount"), default=0) - recorded_products = models.IntegerField(_("recorded product"), default=0) objects = CustomerQuerySet.as_manager() @@ -106,24 +104,50 @@ class Customer(models.Model): def __str__(self): return "%s - %s" % (self.user.username, self.account_id) - def save(self, *args, allow_negative=False, is_selling=False, **kwargs): + def save(self, *args, allow_negative=False, **kwargs): """is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative. """ - if self.amount < 0 and (is_selling and not allow_negative): + if self.amount < 0 and not allow_negative: raise ValidationError(_("Not enough money")) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("core:user_account", kwargs={"user_id": self.user.pk}) - @property - def can_record(self): - return self.recorded_products > -settings.SITH_ECOCUP_LIMIT + def update_returnable_balance(self): + """Update all returnable balances of this user to their real amount.""" - def can_record_more(self, number): - return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT + def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]): + return ( + Selling.objects.filter(customer=self, product=OuterRef(outer_ref)) + .values("product") + .annotate(quantity=Sum("quantity", default=0)) + .values("quantity") + ) + + balances = ( + ReturnableProduct.objects.annotate_balance_for(self) + .annotate( + nb_cons=Coalesce(Subquery(purchases_qs("product_id")), 0), + nb_dcons=Coalesce(Subquery(purchases_qs("returned_product_id")), 0), + ) + .annotate(new_balance=F("nb_cons") - F("nb_dcons")) + .values("id", "new_balance") + ) + updated_balances = [ + ReturnableProductBalance( + customer=self, returnable_id=b["id"], balance=b["new_balance"] + ) + for b in balances + ] + ReturnableProductBalance.objects.bulk_create( + updated_balances, + update_conflicts=True, + update_fields=["balance"], + unique_fields=["customer", "returnable"], + ) @property def can_buy(self) -> bool: @@ -379,14 +403,6 @@ class Product(models.Model): def get_absolute_url(self): return reverse("counter:product_list") - @property - def is_record_product(self): - return self.id == settings.SITH_ECOCUP_CONS - - @property - def is_unrecord_product(self): - return self.id == settings.SITH_ECOCUP_DECO - def is_owned_by(self, user): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: @@ -514,11 +530,6 @@ class Counter(models.Model): def __str__(self): return self.name - def get_absolute_url(self) -> str: - if self.type == "EBOUTIC": - return reverse("eboutic:main") - return reverse("counter:details", kwargs={"counter_id": self.id}) - def __getattribute__(self, name: str): if name == "edit_groups": return Group.objects.filter( @@ -526,6 +537,11 @@ class Counter(models.Model): ).all() return object.__getattribute__(self, name) + def get_absolute_url(self) -> str: + if self.type == "EBOUTIC": + return reverse("eboutic:main") + return reverse("counter:details", kwargs={"counter_id": self.id}) + def is_owned_by(self, user: User) -> bool: if user.is_anonymous: return False @@ -569,7 +585,7 @@ class Counter(models.Model): if self.type != "BAR": return False # at least one of the barmen is in the AE board - ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"]) + ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) return any(ae.get_membership_for(barman) for barman in self.barmen_list) def get_top_barmen(self) -> QuerySet: @@ -860,7 +876,7 @@ class Selling(models.Model): self.full_clean() if not self.is_validated: self.customer.amount -= self.quantity * self.unit_price - self.customer.save(allow_negative=allow_negative, is_selling=True) + self.customer.save(allow_negative=allow_negative) self.is_validated = True user = self.customer.user if user.was_subscribed: @@ -945,6 +961,7 @@ class Selling(models.Model): self.customer.amount += self.quantity * self.unit_price self.customer.save() super().delete(*args, **kwargs) + self.customer.update_returnable_balance() def send_mail_customer(self): event = self.product.eticket.event_title or _("Unknown event") @@ -1045,14 +1062,6 @@ class CashRegisterSummary(models.Model): def __str__(self): return "At %s by %s - Total: %s €" % (self.counter, self.user, self.get_total()) - def save(self, *args, **kwargs): - if not self.id: - self.date = timezone.now() - return super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse("counter:cash_summary_list") - def __getattribute__(self, name): if name[:5] == "check": checks = self.items.filter(is_check=True).order_by("value").all() @@ -1089,6 +1098,14 @@ class CashRegisterSummary(models.Model): else: return object.__getattribute__(self, name) + def save(self, *args, **kwargs): + if not self.id: + self.date = timezone.now() + return super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("counter:cash_summary_list") + def is_owned_by(self, user): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: @@ -1211,3 +1228,134 @@ class StudentCard(models.Model): if isinstance(obj, User): return StudentCard.can_create(self.customer, obj) return False + + +class ReturnableProductQuerySet(models.QuerySet): + def annotate_balance_for(self, customer: Customer): + return self.annotate( + balance=Coalesce( + Subquery( + ReturnableProductBalance.objects.filter( + returnable=OuterRef("pk"), customer=customer + ).values("balance") + ), + 0, + ) + ) + + +class ReturnableProduct(models.Model): + """A returnable relation between two products (*consigne/déconsigne*).""" + + product = models.OneToOneField( + to=Product, + on_delete=models.CASCADE, + related_name="cons", + verbose_name=_("returnable product"), + ) + returned_product = models.OneToOneField( + to=Product, + on_delete=models.CASCADE, + related_name="dcons", + verbose_name=_("returned product"), + ) + max_return = models.PositiveSmallIntegerField( + _("maximum returns"), + default=0, + help_text=_( + "The maximum number of items a customer can return " + "without having actually bought them." + ), + ) + + objects = ReturnableProductQuerySet.as_manager() + + class Meta: + verbose_name = _("returnable product") + verbose_name_plural = _("returnable products") + constraints = [ + models.CheckConstraint( + check=~Q(product=F("returned_product")), + name="returnableproduct_product_different_from_returned", + violation_error_message=_( + "The returnable product cannot be the same as the returned one" + ), + ) + ] + + def __str__(self): + return f"returnable product ({self.product_id} -> {self.returned_product_id})" + + def update_balances(self): + """Update all returnable balances linked to this object. + + Call this when a ReturnableProduct is created or updated. + + Warning: + This function is expensive (around a few seconds), + so try not to run it outside a management command + or a task. + """ + + def product_balance_subquery(product_id: int): + return Subquery( + Selling.objects.filter(customer=OuterRef("pk"), product_id=product_id) + .values("customer") + .annotate(res=Sum("quantity")) + .values("res") + ) + + old_balance_subquery = Subquery( + ReturnableProductBalance.objects.filter( + customer=OuterRef("pk"), returnable=self + ).values("balance") + ) + new_balances = ( + Customer.objects.annotate( + nb_cons=Coalesce(product_balance_subquery(self.product_id), 0), + nb_dcons=Coalesce( + product_balance_subquery(self.returned_product_id), 0 + ), + ) + .annotate(new_balance=F("nb_cons") - F("nb_dcons")) + .exclude(new_balance=Coalesce(old_balance_subquery, 0)) + .values("pk", "new_balance") + ) + updates = [ + ReturnableProductBalance( + customer_id=c["pk"], returnable=self, balance=c["new_balance"] + ) + for c in new_balances + ] + ReturnableProductBalance.objects.bulk_create( + updates, + update_conflicts=True, + update_fields=["balance"], + unique_fields=["customer_id", "returnable"], + ) + + +class ReturnableProductBalance(models.Model): + """The returnable products balances of a customer""" + + customer = models.ForeignKey( + to=Customer, on_delete=models.CASCADE, related_name="return_balances" + ) + returnable = models.ForeignKey( + to=ReturnableProduct, on_delete=models.CASCADE, related_name="balances" + ) + balance = models.SmallIntegerField(blank=True, default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["customer", "returnable"], + name="returnable_product_unique_type_per_customer", + ) + ] + + def __str__(self): + return ( + f"return balance of {self.customer} " + f"for {self.returnable.product_id} : {self.balance}" + ) diff --git a/counter/schemas.py b/counter/schemas.py index adc8094b..978422a5 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -98,3 +98,5 @@ class ProductFilterSchema(FilterSchema): is_archived: bool | None = Field(None, q="archived") buying_groups: set[int] | None = Field(None, q="buying_groups__in") product_type: set[int] | None = Field(None, q="product_type__in") + club: set[int] | None = Field(None, q="club__in") + counter: set[int] | None = Field(None, q="counters__in") diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index b2b63c80..5b593532 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,6 +1,6 @@ import { BasketItem } from "#counter:counter/basket"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; -import type { CounterProductSelect } from "./components/counter-product-select-index"; +import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; document.addEventListener("alpine:init", () => { Alpine.data("counter", (config: CounterConfig) => ({ diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts index 70403692..a7cd3f86 100644 --- a/counter/static/bundled/counter/product-list-index.ts +++ b/counter/static/bundled/counter/product-list-index.ts @@ -60,6 +60,8 @@ document.addEventListener("alpine:init", () => { productStatus: "" as "active" | "archived" | "both", search: "", productTypes: [] as string[], + clubs: [] as string[], + counters: [] as string[], pageSize: defaultPageSize, page: defaultPage, @@ -67,13 +69,27 @@ document.addEventListener("alpine:init", () => { const url = getCurrentUrlParams(); this.search = url.get("search") || ""; this.productStatus = url.get("productStatus") ?? "active"; - const widget = this.$refs.productTypesInput.widget as TomSelect; - widget.on("change", (items: string[]) => { + const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect; + productTypesWidget.on("change", (items: string[]) => { this.productTypes = [...items]; }); + const clubsWidget = this.$refs.clubsInput.widget as TomSelect; + clubsWidget.on("change", (items: string[]) => { + this.clubs = [...items]; + }); + const countersWidget = this.$refs.countersInput.widget as TomSelect; + countersWidget.on("change", (items: string[]) => { + this.counters = [...items]; + }); await this.load(); - const searchParams = ["search", "productStatus", "productTypes"]; + const searchParams = [ + "search", + "productStatus", + "productTypes", + "clubs", + "counters", + ]; for (const param of searchParams) { this.$watch(param, () => { this.page = defaultPage; @@ -92,7 +108,7 @@ document.addEventListener("alpine:init", () => { * Build the object containing the query parameters corresponding * to the current filters */ - getQueryParams(): ProductSearchProductsDetailedData { + getQueryParams(): Omit { const search = this.search.length > 0 ? this.search : null; // If active or archived products must be filtered, put the filter in the request // Else, don't include the filter @@ -109,6 +125,8 @@ document.addEventListener("alpine:init", () => { is_archived: isArchived, // biome-ignore lint/style/useNamingConvention: api is in snake_case product_type: [...this.productTypes], + club: [...this.clubs], + counter: [...this.counters], }, }; }, @@ -121,14 +139,17 @@ document.addEventListener("alpine:init", () => { const options = this.getQueryParams(); const resp = await productSearchProductsDetailed(options); this.nbPages = Math.ceil(resp.data.count / defaultPageSize); - this.products = resp.data.results.reduce((acc, curr) => { - const key = curr.product_type?.name ?? gettext("Uncategorized"); - if (!(key in acc)) { - acc[key] = []; - } - acc[key].push(curr); - return acc; - }, {}); + this.products = resp.data.results.reduce( + (acc: GroupedProducts, curr: ProductSchema) => { + const key = curr.product_type?.name ?? gettext("Uncategorized"); + if (!(key in acc)) { + acc[key] = []; + } + acc[key].push(curr); + return acc; + }, + {}, + ); this.loading = false; }, diff --git a/counter/templates/counter/product_list.jinja b/counter/templates/counter/product_list.jinja index 0ee8dffa..9644e88f 100644 --- a/counter/templates/counter/product_list.jinja +++ b/counter/templates/counter/product_list.jinja @@ -7,6 +7,7 @@ {% block additional_js %} + {% endblock %} @@ -22,7 +23,6 @@

    {% trans %}Filter products{% endtrans %}

    -
    -
    - - - -
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +

    {% trans %}Product list{% endtrans %}

    diff --git a/accounting/templates/accounting/refound_account.jinja b/counter/templates/counter/refound_account.jinja similarity index 100% rename from accounting/templates/accounting/refound_account.jinja rename to counter/templates/counter/refound_account.jinja diff --git a/counter/templates/counter/returnable_list.jinja b/counter/templates/counter/returnable_list.jinja new file mode 100644 index 00000000..cc107a30 --- /dev/null +++ b/counter/templates/counter/returnable_list.jinja @@ -0,0 +1,67 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans %}Returnable products{% endtrans %} +{% endblock %} + +{% block additional_js %} +{% endblock %} + +{% block additional_css %} + + + + +{% endblock %} + +{% block content %} +

    {% trans %}Returnable products{% endtrans %}

    + {% if user.has_perm("counter.add_returnableproduct") %} +
    + {% trans %}New returnable product{% endtrans %} + + {% endif %} + +{% endblock content %} diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index d50bb6c4..f0773897 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -28,17 +28,19 @@ from django.utils import timezone from django.utils.timezone import localdate, now from freezegun import freeze_time from model_bakery import baker +from pytest_django.asserts import assertRedirects from club.models import Club, Membership from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.models import BanGroup, User -from counter.baker_recipes import product_recipe +from counter.baker_recipes import product_recipe, sale_recipe from counter.models import ( Counter, Customer, Permanency, Product, Refilling, + ReturnableProduct, Selling, ) @@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase): self, user: User | Customer, counter: Counter, - amount: int, + amount: int | float, client: Client | None = None, ) -> HttpResponse: used_client = client if client is not None else self.client @@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase): special_selling_price="-1.5", ) cls.beer = product_recipe.make( - limit_age=18, selling_price="1.5", special_selling_price="1" + limit_age=18, selling_price=1.5, special_selling_price=1 ) cls.beer_tap = product_recipe.make( - limit_age=18, - tray=True, - selling_price="1.5", - special_selling_price="1", + limit_age=18, tray=True, selling_price=1.5, special_selling_price=1 ) - cls.snack = product_recipe.make( - limit_age=0, selling_price="1.5", special_selling_price="1" + limit_age=0, selling_price=1.5, special_selling_price=1 ) cls.stamps = product_recipe.make( - limit_age=0, selling_price="1.5", special_selling_price="1" + limit_age=0, selling_price=1.5, special_selling_price=1 + ) + ReturnableProduct.objects.all().delete() + cls.cons = baker.make(Product, selling_price=1) + cls.dcons = baker.make(Product, selling_price=-1) + baker.make( + ReturnableProduct, + product=cls.cons, + returned_product=cls.dcons, + max_return=3, ) - - cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS) - cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO) cls.counter.products.add( cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons ) - cls.other_counter.products.add(cls.snack) - cls.club_counter.products.add(cls.stamps) def login_in_bar(self, barmen: User | None = None): @@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase): def test_click_eboutic_failure(self): eboutic = baker.make(Counter, type="EBOUTIC") self.client.force_login(self.club_admin) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.stamps.id, 5)], - counter=eboutic, - ).status_code - == 404 + res = self.submit_basket( + self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic ) + assert res.status_code == 404 def test_click_office_success(self): self.refill_user(self.customer, 10) self.client.force_login(self.club_admin) - - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.stamps.id, 5)], - counter=self.club_counter, - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("2.5") # Test no special price on office counter self.refill_user(self.club_admin, 10) - - assert ( - self.submit_basket( - self.club_admin, - [BasketItem(self.stamps.id, 1)], - counter=self.club_counter, - ).status_code - == 302 + res = self.submit_basket( + self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter ) + assert res.status_code == 302 assert self.updated_amount(self.club_admin) == Decimal("8.5") def test_click_bar_success(self): self.refill_user(self.customer, 10) self.login_in_bar(self.barmen) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer.id, 2), - BasketItem(self.snack.id, 1), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("5.5") @@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase): self.login_in_bar(self.barmen) # Not applying tray price - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 2), - ], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("17") # Applying tray price - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 7), - ], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("8") def test_click_alcool_unauthorized(self): @@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase): self.refill_user(user, 10) # Buy product without age limit - assert ( - self.submit_basket( - user, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 - ) + res = self.submit_basket(user, [BasketItem(self.snack.id, 2)]) + assert res.status_code == 302 assert self.updated_amount(user) == Decimal("7") # Buy product without age limit - assert ( - self.submit_basket( - user, - [ - BasketItem(self.beer.id, 2), - ], - ).status_code - == 200 - ) + res = self.submit_basket(user, [BasketItem(self.beer.id, 2)]) + assert res.status_code == 200 assert self.updated_amount(user) == Decimal("7") @@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase): self.customer_old_can_not_buy, ]: self.refill_user(user, 10) - resp = self.submit_basket( - user, - [ - BasketItem(self.snack.id, 2), - ], - ) + resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)]) assert resp.status_code == 302 assert resp.url == resolve_url(self.counter) @@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase): def test_click_user_without_customer(self): self.login_in_bar() - assert ( - self.submit_basket( - self.customer_can_not_buy, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 404 + res = self.submit_basket( + self.customer_can_not_buy, [BasketItem(self.snack.id, 2)] ) + assert res.status_code == 404 def test_click_allowed_old_subscriber(self): self.login_in_bar() self.refill_user(self.customer_old_can_buy, 10) - assert ( - self.submit_basket( - self.customer_old_can_buy, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer_old_can_buy, [BasketItem(self.snack.id, 2)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer_old_can_buy) == Decimal("7") def test_click_wrong_counter(self): self.login_in_bar() self.refill_user(self.customer, 10) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.other_counter, - ).status_code - == 302 # Redirect to counter main + res = self.submit_basket( + self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter ) + assertRedirects(res, self.other_counter.get_absolute_url()) # We want to test sending requests from another counter while # we are currently registered to another counter @@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase): # that using a client not logged to a counter # where another client is logged still isn't authorized. client = Client() - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.counter, - client=client, - ).status_code - == 302 # Redirect to counter main + res = self.submit_basket( + self.customer, + [BasketItem(self.snack.id, 2)], + counter=self.counter, + client=client, ) + assertRedirects(res, self.counter.get_absolute_url()) assert self.updated_amount(self.customer) == Decimal("10") def test_click_not_connected(self): self.refill_user(self.customer, 10) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 # Redirect to counter main - ) + res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)]) + assertRedirects(res, self.counter.get_absolute_url()) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.club_counter, - ).status_code - == 403 + res = self.submit_basket( + self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter ) + assert res.status_code == 403 assert self.updated_amount(self.customer) == Decimal("10") @@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase): self.refill_user(self.customer, 10) self.login_in_bar() - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.stamps.id, 2), - ], - ).status_code - == 200 - ) + res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)]) + assert res.status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") def test_click_product_invalid(self): @@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase): self.login_in_bar() for item in [ - BasketItem("-1", 2), + BasketItem(-1, 2), BasketItem(self.beer.id, -1), BasketItem(None, 1), BasketItem(self.beer.id, None), BasketItem(None, None), ]: - assert ( - self.submit_basket( - self.customer, - [item], - ).status_code - == 200 - ) + assert self.submit_basket(self.customer, [item]).status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") def test_click_not_enough_money(self): self.refill_user(self.customer, 10) self.login_in_bar() - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 5), - BasketItem(self.beer.id, 10), - ], - ).status_code - == 200 + res = self.submit_basket( + self.customer, + [BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)], ) + assert res.status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") @@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase): def test_selling_ordering(self): # Cheaper items should be processed with a higher priority self.login_in_bar(self.barmen) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer.id, 1), - BasketItem(self.gift.id, 1), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == 0 def test_recordings(self): self.refill_user(self.customer, self.cons.selling_price * 3) self.login_in_bar(self.barmen) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.cons.id, 3)], - ).status_code - == 302 - ) + res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == 0 + assert list( + self.customer.customer.return_balances.values("returnable", "balance") + ) == [{"returnable": self.cons.cons.id, "balance": 3}] - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 3)], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == self.dcons.selling_price * -3 - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)] ) + # from now on, the user amount should not change + expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return) + assert res.status_code == 302 + assert self.updated_amount(self.customer) == expected_amount - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT - ) + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert res.status_code == 200 + assert self.updated_amount(self.customer) == expected_amount - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 1)], - ).status_code - == 200 - ) - - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT - ) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.cons.id, 1), - BasketItem(self.dcons.id, 1), - ], - ).status_code - == 302 - ) - - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT + res = self.submit_basket( + self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)] ) + assert res.status_code == 302 + assert self.updated_amount(self.customer) == expected_amount def test_recordings_when_negative(self): - self.refill_user( - self.customer, - self.cons.selling_price * 3 + Decimal(self.beer.selling_price), + sale_recipe.make( + customer=self.customer.customer, + product=self.dcons, + unit_price=self.dcons.selling_price, + quantity=10, ) - self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10 - self.customer.customer.save() + self.customer.customer.update_returnable_balance() self.login_in_bar(self.barmen) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 1)], - ).status_code - == 200 - ) - assert self.updated_amount( - self.customer - ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.cons.id, 3)], - ).status_code - == 302 - ) - assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price) + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert res.status_code == 200 + assert self.updated_amount(self.customer) == self.dcons.selling_price * -10 + res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) + assert res.status_code == 302 assert ( - self.submit_basket( - self.customer, - [BasketItem(self.beer.id, 1)], - ).status_code - == 302 + self.updated_amount(self.customer) + == self.dcons.selling_price * -10 - self.cons.selling_price * 3 + ) + + res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)]) + assert res.status_code == 302 + assert ( + self.updated_amount(self.customer) + == self.dcons.selling_price * -10 + - self.cons.selling_price * 3 + - self.beer.selling_price ) - assert self.updated_amount(self.customer) == 0 class TestCounterStats(TestCase): @@ -783,7 +634,7 @@ class TestCounterStats(TestCase): s = Selling( label=barbar.name, product=barbar, - club=Club.objects.get(name=settings.SITH_MAIN_CLUB["name"]), + club=baker.make(Club), counter=cls.counter, unit_price=2, seller=cls.skia, diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py index bc3f4fb4..56daccb1 100644 --- a/counter/tests/test_customer.py +++ b/counter/tests/test_customer.py @@ -14,12 +14,13 @@ from model_bakery import baker from club.models import Membership from core.baker_recipes import board_user, subscriber_user from core.models import User -from counter.baker_recipes import refill_recipe, sale_recipe +from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.models import ( BillingInfo, Counter, Customer, Refilling, + ReturnableProduct, Selling, StudentCard, ) @@ -482,3 +483,31 @@ def test_update_balance(): for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False): customer.refresh_from_db() assert customer.amount == amount + + +@pytest.mark.django_db +def test_update_returnable_balance(): + ReturnableProduct.objects.all().delete() + customer = baker.make(Customer) + products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True) + returnables = [ + baker.make( + ReturnableProduct, product=products[0], returned_product=products[1] + ), + baker.make( + ReturnableProduct, product=products[2], returned_product=products[3] + ), + ] + balance_qs = ReturnableProduct.objects.annotate_balance_for(customer) + assert not customer.return_balances.exists() + assert list(balance_qs.values_list("balance", flat=True)) == [0, 0] + + sale_recipe.make(customer=customer, product=products[0], unit_price=0, quantity=5) + sale_recipe.make(customer=customer, product=products[2], unit_price=0, quantity=1) + sale_recipe.make(customer=customer, product=products[3], unit_price=0, quantity=3) + customer.update_returnable_balance() + assert list(customer.return_balances.values("returnable_id", "balance")) == [ + {"returnable_id": returnables[0].id, "balance": 5}, + {"returnable_id": returnables[1].id, "balance": -2}, + ] + assert set(balance_qs.values_list("balance", flat=True)) == {-2, 5} diff --git a/counter/tests/test_refound.py b/counter/tests/test_refound.py new file mode 100644 index 00000000..25c3578c --- /dev/null +++ b/counter/tests/test_refound.py @@ -0,0 +1,59 @@ +# +# Copyright 2023 © AE UTBM +# ae@utbm.fr / ae.info@utbm.fr +# +# This file is part of the website of the UTBM Student Association (AE UTBM), +# https://ae.utbm.fr. +# +# You can find the source code of the website at https://github.com/ae-utbm/sith +# +# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) +# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE +# OR WITHIN THE LOCAL FILE "LICENSE" +# +# + + +from django.test import TestCase +from django.urls import reverse + +from core.models import User + + +class TestRefoundAccount(TestCase): + @classmethod + def setUpTestData(cls): + cls.skia = User.objects.get(username="skia") + # refill skia's account + cls.skia.customer.amount = 800 + cls.skia.customer.save() + cls.refound_account_url = reverse("counter:account_refound") + + def test_permission_denied(self): + self.client.force_login(User.objects.get(username="guy")) + response_post = self.client.post( + self.refound_account_url, {"user": self.skia.id} + ) + response_get = self.client.get(self.refound_account_url) + assert response_get.status_code == 403 + assert response_post.status_code == 403 + + def test_root_granteed(self): + self.client.force_login(User.objects.get(username="root")) + response = self.client.post(self.refound_account_url, {"user": self.skia.id}) + self.assertRedirects(response, self.refound_account_url) + self.skia.refresh_from_db() + response = self.client.get(self.refound_account_url) + assert response.status_code == 200 + assert '
    ' in str(response.content) + assert self.skia.customer.amount == 0 + + def test_comptable_granteed(self): + self.client.force_login(User.objects.get(username="comptable")) + response = self.client.post(self.refound_account_url, {"user": self.skia.id}) + self.assertRedirects(response, self.refound_account_url) + self.skia.refresh_from_db() + response = self.client.get(self.refound_account_url) + assert response.status_code == 200 + assert '' in str(response.content) + assert self.skia.customer.amount == 0 diff --git a/counter/tests/test_returnable_product.py b/counter/tests/test_returnable_product.py new file mode 100644 index 00000000..b25b45d6 --- /dev/null +++ b/counter/tests/test_returnable_product.py @@ -0,0 +1,37 @@ +import pytest +from model_bakery import baker + +from counter.baker_recipes import refill_recipe, sale_recipe +from counter.models import Customer, ReturnableProduct + + +@pytest.mark.django_db +def test_update_returnable_product_balance(): + Customer.objects.all().delete() + ReturnableProduct.objects.all().delete() + customers = baker.make(Customer, _quantity=2, _bulk_create=True) + refill_recipe.make(customer=iter(customers), _quantity=2, amount=100) + returnable = baker.make(ReturnableProduct) + sale_recipe.make( + unit_price=0, quantity=3, product=returnable.product, customer=customers[0] + ) + sale_recipe.make( + unit_price=0, quantity=1, product=returnable.product, customer=customers[0] + ) + sale_recipe.make( + unit_price=0, + quantity=2, + product=returnable.returned_product, + customer=customers[0], + ) + sale_recipe.make( + unit_price=0, quantity=4, product=returnable.product, customer=customers[1] + ) + + returnable.update_balances() + assert list( + returnable.balances.order_by("customer_id").values("customer_id", "balance") + ) == [ + {"customer_id": customers[0].pk, "balance": 2}, + {"customer_id": customers[1].pk, "balance": 4}, + ] diff --git a/counter/urls.py b/counter/urls.py index 885b4b14..04757aa1 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -30,6 +30,11 @@ from counter.views.admin import ( ProductTypeEditView, ProductTypeListView, RefillingDeleteView, + RefoundAccountView, + ReturnableProductCreateView, + ReturnableProductDeleteView, + ReturnableProductListView, + ReturnableProductUpdateView, SellingDeleteView, ) from counter.views.auth import counter_login, counter_logout @@ -51,10 +56,7 @@ from counter.views.home import ( CounterMain, ) from counter.views.invoice import InvoiceCallView -from counter.views.student_card import ( - StudentCardDeleteView, - StudentCardFormView, -) +from counter.views.student_card import StudentCardDeleteView, StudentCardFormView urlpatterns = [ path("/", CounterMain.as_view(), name="details"), @@ -129,6 +131,24 @@ urlpatterns = [ ProductTypeEditView.as_view(), name="product_type_edit", ), + path( + "admin/returnable/", ReturnableProductListView.as_view(), name="returnable_list" + ), + path( + "admin/returnable/create/", + ReturnableProductCreateView.as_view(), + name="create_returnable", + ), + path( + "admin/returnable//", + ReturnableProductUpdateView.as_view(), + name="edit_returnable", + ), + path( + "admin/returnable/delete//", + ReturnableProductDeleteView.as_view(), + name="delete_returnable", + ), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path( @@ -151,4 +171,5 @@ urlpatterns = [ CounterRefillingListView.as_view(), name="refilling_list", ), + path("admin/refound/", RefoundAccountView.as_view(), name="account_refound"), ] diff --git a/counter/views/admin.py b/counter/views/admin.py index ffe81ea0..f7d4a66b 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -15,19 +15,34 @@ from datetime import timedelta from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.core.exceptions import PermissionDenied +from django.db import transaction from django.forms import CheckboxSelectMultiple from django.forms.models import modelform_factory from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.translation import gettext as _ from django.views.generic import DetailView, ListView, TemplateView -from django.views.generic.edit import CreateView, DeleteView, UpdateView +from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from core.auth.mixins import CanEditMixin, CanViewMixin from core.utils import get_semester_code, get_start_of_semester -from counter.forms import CounterEditForm, ProductEditForm -from counter.models import Counter, Product, ProductType, Refilling, Selling +from counter.forms import ( + CloseCustomerAccountForm, + CounterEditForm, + ProductEditForm, + ReturnableProductForm, +) +from counter.models import ( + Counter, + Product, + ProductType, + Refilling, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin @@ -146,6 +161,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ReturnableProductListView( + CounterAdminTabsMixin, PermissionRequiredMixin, ListView +): + model = ReturnableProduct + queryset = model.objects.select_related("product", "returned_product") + template_name = "counter/returnable_list.jinja" + current_tab = "returnable_products" + permission_required = "counter.view_returnableproduct" + + +class ReturnableProductCreateView( + CounterAdminTabsMixin, PermissionRequiredMixin, CreateView +): + form_class = ReturnableProductForm + template_name = "core/create.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.add_returnableproduct" + + +class ReturnableProductUpdateView( + CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView +): + model = ReturnableProduct + pk_url_kwarg = "returnable_id" + queryset = model.objects.select_related("product", "returned_product") + form_class = ReturnableProductForm + template_name = "core/edit.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.change_returnableproduct" + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": _("returnable product : %(returnable)s -> %(returned)s") + % { + "returnable": self.object.product.name, + "returned": self.object.returned_product.name, + } + } + + +class ReturnableProductDeleteView( + CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView +): + model = ReturnableProduct + pk_url_kwarg = "returnable_id" + queryset = model.objects.select_related("product", "returned_product") + template_name = "core/delete_confirm.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.delete_returnableproduct" + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": _("returnable product : %(returnable)s -> %(returned)s") + % { + "returnable": self.object.product.name, + "returned": self.object.returned_product.name, + } + } + + class RefillingDeleteView(DeleteView): """Delete a refilling (for the admins).""" @@ -253,3 +331,42 @@ class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListVie kwargs = super().get_context_data(**kwargs) kwargs["counter"] = self.counter return kwargs + + +class RefoundAccountView(UserPassesTestMixin, FormView): + """Create a selling with the same amount as the current user money.""" + + template_name = "counter/refound_account.jinja" + form_class = CloseCustomerAccountForm + + def test_func(self): + return self.request.user.is_root or self.request.user.is_in_group( + pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID + ) + + def form_valid(self, form): + self.customer = form.cleaned_data["user"] + self.create_selling() + return super().form_valid(form) + + def get_success_url(self): + return self.request.path + + def create_selling(self): + with transaction.atomic(): + uprice = self.customer.customer.amount + refound_club_counter = Counter.objects.get( + id=settings.SITH_COUNTER_REFOUND_ID + ) + refound_club = refound_club_counter.club + s = Selling( + label=_("Refound account"), + unit_price=uprice, + quantity=1, + seller=self.request.user, + customer=self.customer.customer, + club=refound_club, + counter=refound_club_counter, + product=Product.objects.get(id=settings.SITH_PRODUCT_REFOUND_ID), + ) + s.save() diff --git a/counter/views/click.py b/counter/views/click.py index 46bf8e62..a44e841f 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -16,6 +16,7 @@ import math from django.core.exceptions import PermissionDenied from django.db import transaction +from django.db.models import Q from django.forms import ( BaseFormSet, Form, @@ -35,7 +36,13 @@ from core.auth.mixins import CanViewMixin from core.models import User from core.utils import FormFragmentTemplateData from counter.forms import RefillForm -from counter.models import Counter, Customer, Product, Selling +from counter.models import ( + Counter, + Customer, + Product, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormView @@ -99,17 +106,22 @@ class ProductForm(Form): class BaseBasketForm(BaseFormSet): def clean(self): - super().clean() - if len(self) == 0: + if len(self.forms) == 0: return self._check_forms_have_errors() + self._check_product_are_unique() self._check_recorded_products(self[0].customer) self._check_enough_money(self[0].counter, self[0].customer) def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): - raise ValidationError(_("Submmited basket is invalid")) + raise ValidationError(_("Submitted basket is invalid")) + + def _check_product_are_unique(self): + product_ids = {form.cleaned_data["id"] for form in self.forms} + if len(product_ids) != len(self.forms): + raise ValidationError(_("Duplicated product entries.")) def _check_enough_money(self, counter: Counter, customer: Customer): self.total_price = sum([data["total_price"] for data in self.cleaned_data]) @@ -118,21 +130,32 @@ class BaseBasketForm(BaseFormSet): def _check_recorded_products(self, customer: Customer): """Check for, among other things, ecocups and pitchers""" - self.total_recordings = 0 - for form in self: - # form.product is stored by the clean step of each formset form - if form.product.is_record_product: - self.total_recordings -= form.cleaned_data["quantity"] - if form.product.is_unrecord_product: - self.total_recordings += form.cleaned_data["quantity"] - - # We don't want to block an user that have negative recordings - # if he isn't recording anything or reducing it's recording count - if self.total_recordings <= 0: - return - - if not customer.can_record_more(self.total_recordings): - raise ValidationError(_("This user have reached his recording limit")) + items = { + form.cleaned_data["id"]: form.cleaned_data["quantity"] + for form in self.forms + } + ids = list(items.keys()) + returnables = list( + ReturnableProduct.objects.filter( + Q(product_id__in=ids) | Q(returned_product_id__in=ids) + ).annotate_balance_for(customer) + ) + limit_reached = [] + for returnable in returnables: + returnable.balance += items.get(returnable.product_id, 0) + for returnable in returnables: + dcons = items.get(returnable.returned_product_id, 0) + returnable.balance -= dcons + if dcons and returnable.balance < -returnable.max_return: + limit_reached.append(returnable.returned_product) + if limit_reached: + raise ValidationError( + _( + "This user have reached his recording limit " + "for the following products : %s" + ) + % ", ".join([str(p) for p in limit_reached]) + ) BasketForm = formset_factory( @@ -238,8 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): customer=self.customer, ).save() - self.customer.recorded_products -= formset.total_recordings - self.customer.save() + self.customer.update_returnable_balance() # Add some info for the main counter view to display self.request.session["last_customer"] = self.customer.user.get_display_name() @@ -248,6 +270,37 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): return ret + def _update_returnable_balance(self, formset): + ids = [form.cleaned_data["id"] for form in formset] + returnables = list( + ReturnableProduct.objects.filter( + Q(product_id__in=ids) | Q(returned_product_id__in=ids) + ).annotate_balance_for(self.customer) + ) + for returnable in returnables: + cons_quantity = next( + ( + form.cleaned_data["quantity"] + for form in formset + if form.cleaned_data["id"] == returnable.product_id + ), + 0, + ) + dcons_quantity = next( + ( + form.cleaned_data["quantity"] + for form in formset + if form.cleaned_data["id"] == returnable.returned_product_id + ), + 0, + ) + self.customer.return_balances.update_or_create( + returnable=returnable, + defaults={ + "balance": returnable.balance + cons_quantity - dcons_quantity + }, + ) + def get_success_url(self): return resolve_url(self.object) diff --git a/counter/views/invoice.py b/counter/views/invoice.py index dbd6e7cb..cabbccdb 100644 --- a/counter/views/invoice.py +++ b/counter/views/invoice.py @@ -19,7 +19,7 @@ from django.db.models import F from django.utils import timezone from django.views.generic import TemplateView -from accounting.models import CurrencyField +from counter.fields import CurrencyField from counter.models import Refilling, Selling from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin diff --git a/counter/views/mixins.py b/counter/views/mixins.py index 5c01392a..90bd1291 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin): "slug": "product_types", "name": _("Product types"), }, + { + "url": reverse_lazy("counter:returnable_list"), + "slug": "returnable_products", + "name": _("Returnable products"), + }, { "url": reverse_lazy("counter:cash_summary_list"), "slug": "cash_summary", diff --git a/counter/widgets/select.py b/counter/widgets/ajax_select.py similarity index 93% rename from counter/widgets/select.py rename to counter/widgets/ajax_select.py index 875c0aca..45888061 100644 --- a/counter/widgets/select.py +++ b/counter/widgets/ajax_select.py @@ -1,6 +1,9 @@ from pydantic import TypeAdapter -from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple +from core.views.widgets.ajax_select import ( + AutoCompleteSelect, + AutoCompleteSelectMultiple, +) from counter.models import Counter, Product, ProductType from counter.schemas import ( ProductTypeSchema, diff --git a/docs/tutorial/install-advanced.md b/docs/tutorial/install-advanced.md index 7b4fe493..2da2fc42 100644 --- a/docs/tutorial/install-advanced.md +++ b/docs/tutorial/install-advanced.md @@ -77,6 +77,58 @@ uv sync --group prod C'est parce que ces dépendances compilent certains modules à l'installation. +## Désactiver Honcho + +Honcho est utilisé en développement pour simplifier la gestion +des services externes (redis, vite et autres futures). + +En mode production, il est nécessaire de le désactiver puisque normalement +tous ces services sont déjà configurés. + +Pour désactiver Honcho il suffit de ne sélectionner aucun `PROCFILE_` dans la config. + +```dotenv +PROCFILE_STATIC= +PROCFILE_SERVICE= +``` + +!!! note + + Si `PROCFILE_STATIC` est désactivé, la recompilation automatique + des fichiers statiques ne se fait plus. + Si vous en avez besoin et que vous travaillez sans `PROCFILE_STATIC`, + vous devez ouvrir une autre fenêtre de votre terminal + et lancer la commande `npm run serve` + +## Configurer Redis en service externe + +Redis est installé comme dépendance mais pas lancé par défaut. + +En mode développement, le sith se charge de le démarrer mais +pas en production ! + +Il faut donc lancer le service comme ceci: + +```bash +sudo systemctl start redis +sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot +``` + +Puis modifiez votre `.env` pour y configurer le bon port redis. +Le port du fichier d'exemple est un port non standard pour éviter +les conflits avec les instances de redis déjà en fonctionnement. + +```dotenv +REDIS_PORT=6379 +CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0 +``` + +Si on souhaite configurer redis pour communiquer via un socket : + +```dovenv +CACHE_URL=redis:///path/to/redis-server.sock +``` + ## Configurer PostgreSQL PostgreSQL est utilisé comme base de données. diff --git a/docs/tutorial/install.md b/docs/tutorial/install.md index 0a621587..a4635c02 100644 --- a/docs/tutorial/install.md +++ b/docs/tutorial/install.md @@ -100,14 +100,6 @@ cd /mnt//vos/fichiers/comme/dhab Python ne fait pas parti des dépendances puisqu'il est automatiquement installé par uv. -Parmi les dépendances installées se trouve redis (que nous utilisons comme cache). -Redis est un service qui doit être activé pour être utilisé. -Pour cela, effectuez les commandes : - -```bash -sudo systemctl start redis -sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot -``` ## Finaliser l'installation @@ -179,6 +171,11 @@ uv run ./manage.py runserver [http://localhost:8000](http://localhost:8000) ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/). +!!!note + + Le serveur de développement se charge de lancer redis + et les autres services nécessaires au bon fonctionnement du site. + !!!tip Vous trouverez également, à l'adresse diff --git a/docs/tutorial/structure.md b/docs/tutorial/structure.md index aff331d2..7c740bde 100644 --- a/docs/tutorial/structure.md +++ b/docs/tutorial/structure.md @@ -66,20 +66,24 @@ sith/ │ └── ... ├── staticfiles/ (23) │ └── ... +├── processes/ (24) +│ └── ... │ -├── .coveragerc (24) -├── .envrc (25) +├── .coveragerc (25) +├── .envrc (26) ├── .gitattributes ├── .gitignore ├── .mailmap -├── .env (26) -├── .env.example (27) -├── manage.py (28) -├── mkdocs.yml (29) +├── .env (27) +├── .env.example (28) +├── manage.py (29) +├── mkdocs.yml (30) ├── uv.lock -├── pyproject.toml (30) -├── .venv/ (31) -├── .python-version (32) +├── pyproject.toml (31) +├── .venv/ (32) +├── .python-version (33) +├── Procfile.static (34) +├── Procfile.service (35) └── README.md ``` @@ -121,22 +125,27 @@ sith/ 23. Gestion des statics du site. Override le système de statics de Django. Ajoute l'intégration du scss et du bundler js de manière transparente pour l'utilisateur. -24. Fichier de configuration de coverage. -25. Fichier de configuration de direnv. -26. Contient les variables d'environnement, qui sont susceptibles +24. Module de gestion des services externes. + Offre une API simple pour utiliser les fichiers `Procfile.*`. +25. Fichier de configuration de coverage. +26. Fichier de configuration de direnv. +27. Contient les variables d'environnement, qui sont susceptibles de varier d'une machine à l'autre. -27. Contient des valeurs par défaut pour le `.env` +28. Contient des valeurs par défaut pour le `.env` pouvant convenir à un environnment de développement local -28. Fichier généré automatiquement par Django. C'est lui +29. Fichier généré automatiquement par Django. C'est lui qui permet d'appeler des commandes de gestion du projet avec la syntaxe `python ./manage.py ` -29. Le fichier de configuration de la documentation, +30. Le fichier de configuration de la documentation, avec ses plugins et sa table des matières. -30. Le fichier où sont déclarés les dépendances et la configuration +31. Le fichier où sont déclarés les dépendances et la configuration de certaines d'entre elles. -31. Dossier d'environnement virtuel généré par uv -32. Fichier qui contrôle quelle version de python utiliser pour le projet - +32. Dossier d'environnement virtuel généré par uv +33. Fichier qui contrôle quelle version de python utiliser pour le projet +34. Fichier qui contrôle les commandes à lancer pour gérer la compilation + automatique des static et autres services nécessaires à la command runserver. +35. Fichier qui contrôle les services tiers nécessaires au fonctionnement + du Sith tel que redis. ## L'application principale @@ -220,4 +229,4 @@ comme suit : L'organisation peut éventuellement être un peu différente pour certaines applications, mais le principe -général est le même. \ No newline at end of file +général est le même. diff --git a/eboutic/migrations/0001_initial.py b/eboutic/migrations/0001_initial.py index 3e3730f3..1fa8c248 100644 --- a/eboutic/migrations/0001_initial.py +++ b/eboutic/migrations/0001_initial.py @@ -4,7 +4,7 @@ import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import accounting.models +import counter.fields class Migration(migrations.Migration): @@ -55,7 +55,7 @@ class Migration(migrations.Migration): ("type_id", models.IntegerField(verbose_name="product type id")), ( "product_unit_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="unit price" ), ), @@ -120,7 +120,7 @@ class Migration(migrations.Migration): ("type_id", models.IntegerField(verbose_name="product type id")), ( "product_unit_price", - accounting.models.CurrencyField( + counter.fields.CurrencyField( decimal_places=2, max_digits=12, verbose_name="unit price" ), ), diff --git a/eboutic/models.py b/eboutic/models.py index 980137b5..cd55d0ae 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -25,8 +25,8 @@ from django.db.models import F, OuterRef, Subquery, Sum from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from accounting.models import CurrencyField from core.models import User +from counter.fields import CurrencyField from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling diff --git a/election/views.py b/election/views.py index cd367b63..25866422 100644 --- a/election/views.py +++ b/election/views.py @@ -13,12 +13,12 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.views.forms import SelectDateTime -from core.views.widgets.markdown import MarkdownInput -from core.views.widgets.select import ( +from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultipleGroup, AutoCompleteSelectUser, ) +from core.views.widgets.markdown import MarkdownInput from election.models import Candidature, Election, ElectionList, Role, Vote if TYPE_CHECKING: diff --git a/forum/views.py b/forum/views.py index 9501cf1b..3ce1fa68 100644 --- a/forum/views.py +++ b/forum/views.py @@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from haystack.query import RelatedSearchQuerySet from honeypot.decorators import check_honeypot -from club.widgets.select import AutoCompleteSelectClub +from club.widgets.ajax_select import AutoCompleteSelectClub from core.auth.mixins import ( CanCreateMixin, CanEditMixin, @@ -50,11 +50,11 @@ from core.auth.mixins import ( CanViewMixin, can_view, ) -from core.views.widgets.markdown import MarkdownInput -from core.views.widgets.select import ( +from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultipleGroup, ) +from core.views.widgets.markdown import MarkdownInput from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic diff --git a/galaxy/management/commands/generate_galaxy_test_data.py b/galaxy/management/commands/generate_galaxy_test_data.py index d0dea4a5..0c5614d6 100644 --- a/galaxy/management/commands/generate_galaxy_test_data.py +++ b/galaxy/management/commands/generate_galaxy_test_data.py @@ -86,7 +86,7 @@ class Command(BaseCommand): self.logger.info("The Galaxy is being populated by the Sith.") self.logger.info("Cleaning old Galaxy population") - Club.objects.filter(unix_name__startswith="galaxy-").delete() + Club.objects.filter(name__startswith="galaxy-").delete() Group.objects.filter(name__startswith="galaxy-").delete() Page.objects.filter(name__startswith="galaxy-").delete() User.objects.filter(username__startswith="galaxy-").delete() @@ -127,15 +127,19 @@ class Command(BaseCommand): # the galaxy doesn't care about the club groups, # but it's necessary to add them nonetheless in order # not to break the integrity constraints + pages = Page.objects.bulk_create( + [Page(name="page", _full_name="page") for _ in range(self.NB_CLUBS)] + ) self.clubs = Club.objects.bulk_create( [ Club( - unix_name=f"galaxy-club-{i}", - name=f"club-{i}", + name=f"galaxy-club-{i}", + slug_name=f"galaxy-club-{i}", board_group=Group.objects.create(name=f"board {i}"), members_group=Group.objects.create(name=f"members {i}"), + page=page, ) - for i in range(self.NB_CLUBS) + for i, page in enumerate(pages) ] ) diff --git a/galaxy/models.py b/galaxy/models.py index 9316aacf..f306884d 100644 --- a/galaxy/models.py +++ b/galaxy/models.py @@ -525,7 +525,7 @@ class Galaxy(models.Model): self.logger.info( f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining" ) - self.logger.info(f"Speed: {60.0*global_avg_speed:.2f} citizen per minute") + self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute") # We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell # us that this averages to a division by two diff --git a/galaxy/tests.py b/galaxy/tests.py index b9e709ec..fc92fe9e 100644 --- a/galaxy/tests.py +++ b/galaxy/tests.py @@ -33,6 +33,7 @@ from core.models import User from galaxy.models import Galaxy +@pytest.mark.skip(reason="Galaxy is disabled for now") class TestGalaxyModel(TestCase): @classmethod def setUpTestData(cls): @@ -144,6 +145,7 @@ class TestGalaxyModel(TestCase): @pytest.mark.slow +@pytest.mark.skip(reason="Galaxy is disabled for now") class TestGalaxyView(TestCase): @classmethod def setUpTestData(cls): diff --git a/launderette/models.py b/launderette/models.py index 5d6977e2..81357768 100644 --- a/launderette/models.py +++ b/launderette/models.py @@ -47,16 +47,12 @@ class Launderette(models.Model): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: return False - launderette_club = Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ).first() + launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID) m = launderette_club.get_membership_for(user) return bool(m and m.role >= 9) def can_be_edited_by(self, user): - launderette_club = Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ).first() + launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID) m = launderette_club.get_membership_for(user) return bool(m and m.role >= 2) @@ -105,9 +101,7 @@ class Machine(models.Model): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: return False - launderette_club = Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ).first() + launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID) m = launderette_club.get_membership_for(user) return bool(m and m.role >= 9) @@ -154,9 +148,7 @@ class Token(models.Model): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: return False - launderette_club = Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ).first() + launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID) m = launderette_club.get_membership_for(user) return bool(m and m.role >= 9) diff --git a/launderette/views.py b/launderette/views.py index 92a81dad..1b0f111b 100644 --- a/launderette/views.py +++ b/launderette/views.py @@ -196,9 +196,7 @@ class LaunderetteCreateView(PermissionRequiredMixin, CreateView): permission_required = "launderette.add_launderette" def form_valid(self, form): - club = Club.objects.filter( - unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] - ).first() + club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID) c = Counter(name=form.instance.name, club=club, type="OFFICE") c.save() form.instance.counter = c diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 19b164df..25d03fa4 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-25 16:38+0100\n" +"POT-Creation-Date: 2025-04-04 10:35+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -16,767 +16,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: accounting/models.py club/models.py com/models.py counter/models.py -#: forum/models.py launderette/models.py -msgid "name" -msgstr "nom" - -#: accounting/models.py -msgid "street" -msgstr "rue" - -#: accounting/models.py -msgid "city" -msgstr "ville" - -#: accounting/models.py -msgid "postcode" -msgstr "code postal" - -#: accounting/models.py -msgid "country" -msgstr "pays" - -#: accounting/models.py core/models.py -msgid "phone" -msgstr "téléphone" - -#: accounting/models.py -msgid "email" -msgstr "email" - -#: accounting/models.py -msgid "website" -msgstr "site internet" - -#: accounting/models.py -msgid "company" -msgstr "entreprise" - -#: accounting/models.py -msgid "iban" -msgstr "IBAN" - -#: accounting/models.py -msgid "account number" -msgstr "numéro de compte" - -#: accounting/models.py club/models.py com/models.py counter/models.py -#: trombi/models.py -msgid "club" -msgstr "club" - -#: accounting/models.py -msgid "Bank account" -msgstr "Compte en banque" - -#: accounting/models.py -msgid "bank account" -msgstr "compte en banque" - -#: accounting/models.py -msgid "Club account" -msgstr "Compte club" - -#: accounting/models.py -#, python-format -msgid "%(club_account)s on %(bank_account)s" -msgstr "%(club_account)s sur %(bank_account)s" - -#: accounting/models.py club/models.py counter/models.py election/models.py -#: launderette/models.py -msgid "start date" -msgstr "date de début" - -#: accounting/models.py club/models.py counter/models.py election/models.py -msgid "end date" -msgstr "date de fin" - -#: accounting/models.py -msgid "is closed" -msgstr "est fermé" - -#: accounting/models.py -msgid "club account" -msgstr "compte club" - -#: accounting/models.py counter/models.py -msgid "amount" -msgstr "montant" - -#: accounting/models.py -msgid "effective_amount" -msgstr "montant effectif" - -#: accounting/models.py -msgid "General journal" -msgstr "Classeur" - -#: accounting/models.py -msgid "number" -msgstr "numéro" - -#: accounting/models.py -msgid "journal" -msgstr "classeur" - -#: accounting/models.py core/models.py counter/models.py eboutic/models.py -#: forum/models.py -msgid "date" -msgstr "date" - -#: accounting/models.py counter/models.py pedagogy/models.py -msgid "comment" -msgstr "commentaire" - -#: accounting/models.py counter/models.py subscription/models.py -msgid "payment method" -msgstr "méthode de paiement" - -#: accounting/models.py -msgid "cheque number" -msgstr "numéro de chèque" - -#: accounting/models.py eboutic/models.py -msgid "invoice" -msgstr "facture" - -#: accounting/models.py -msgid "is done" -msgstr "est fait" - -#: accounting/models.py -msgid "simple type" -msgstr "type simplifié" - -#: accounting/models.py -msgid "accounting type" -msgstr "type comptable" - -#: accounting/models.py core/models.py counter/models.py -msgid "label" -msgstr "étiquette" - -#: accounting/models.py -msgid "target type" -msgstr "type de cible" - -#: accounting/models.py club/models.py club/templates/club/club_members.jinja -#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja -#: counter/templates/counter/cash_summary_list.jinja -#: counter/templates/counter/stats.jinja -#: launderette/templates/launderette/launderette_admin.jinja -msgid "User" -msgstr "Utilisateur" - -#: accounting/models.py club/models.py club/templates/club/club_detail.jinja -#: com/templates/com/mailing_admin.jinja -#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja -#: core/templates/core/user_clubs.jinja -#: counter/templates/counter/invoices_call.jinja -#: trombi/templates/trombi/edit_profile.jinja -#: trombi/templates/trombi/export.jinja -#: trombi/templates/trombi/user_profile.jinja -msgid "Club" -msgstr "Club" - -#: accounting/models.py core/views/user.py -msgid "Account" -msgstr "Compte" - -#: accounting/models.py -msgid "Company" -msgstr "Entreprise" - -#: accounting/models.py core/models.py sith/settings.py -msgid "Other" -msgstr "Autre" - -#: accounting/models.py -msgid "target id" -msgstr "id de la cible" - -#: accounting/models.py -msgid "target label" -msgstr "nom de la cible" - -#: accounting/models.py -msgid "linked operation" -msgstr "opération liée" - -#: accounting/models.py -msgid "The date must be set." -msgstr "La date doit être indiquée." - -#: accounting/models.py -#, python-format -msgid "" -"The date can not be before the start date of the journal, which is\n" -"%(start_date)s." -msgstr "" -"La date ne peut pas être avant la date de début du journal, qui est\n" -"%(start_date)s." - -#: accounting/models.py -msgid "Target does not exists" -msgstr "La cible n'existe pas." - -#: accounting/models.py -msgid "Please add a target label if you set no existing target" -msgstr "" -"Merci d'ajouter un nom de cible si vous ne spécifiez pas de cible existante" - -#: accounting/models.py -msgid "" -"You need to provide ether a simplified accounting type or a standard " -"accounting type" -msgstr "" -"Vous devez fournir soit un type comptable simplifié ou un type comptable " -"standard" - -#: accounting/models.py counter/models.py pedagogy/models.py -msgid "code" -msgstr "code" - -#: accounting/models.py -msgid "An accounting type code contains only numbers" -msgstr "Un code comptable ne contient que des numéros" - -#: accounting/models.py -msgid "movement type" -msgstr "type de mouvement" - -#: accounting/models.py -#: accounting/templates/accounting/journal_statement_nature.jinja -#: accounting/templates/accounting/journal_statement_person.jinja -#: accounting/views.py -msgid "Credit" -msgstr "Crédit" - -#: accounting/models.py -#: accounting/templates/accounting/journal_statement_nature.jinja -#: accounting/templates/accounting/journal_statement_person.jinja -#: accounting/views.py -msgid "Debit" -msgstr "Débit" - -#: accounting/models.py -msgid "Neutral" -msgstr "Neutre" - -#: accounting/models.py -msgid "simplified accounting types" -msgstr "type simplifié" - -#: accounting/models.py -msgid "simplified type" -msgstr "type simplifié" - -#: accounting/templates/accounting/accountingtype_list.jinja -msgid "Accounting type list" -msgstr "Liste des types comptable" - -#: accounting/templates/accounting/accountingtype_list.jinja -#: accounting/templates/accounting/bank_account_details.jinja -#: accounting/templates/accounting/bank_account_list.jinja -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/label_list.jinja -#: accounting/templates/accounting/operation_edit.jinja -#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja -#: core/templates/core/user_tools.jinja -msgid "Accounting" -msgstr "Comptabilité" - -#: accounting/templates/accounting/accountingtype_list.jinja -msgid "Accounting types" -msgstr "Type comptable" - -#: accounting/templates/accounting/accountingtype_list.jinja -msgid "New accounting type" -msgstr "Nouveau type comptable" - -#: accounting/templates/accounting/accountingtype_list.jinja -#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja -msgid "There is no types in this website." -msgstr "Il n'y a pas de types comptable dans ce site web." - -#: accounting/templates/accounting/bank_account_details.jinja -#: core/templates/core/user_tools.jinja -msgid "Bank account: " -msgstr "Compte en banque : " - -#: accounting/templates/accounting/bank_account_details.jinja -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/label_list.jinja -#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja -#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja -#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja -#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja -#: core/templates/core/file_detail.jinja -#: core/templates/core/file_moderation.jinja -#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja -#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja -#: core/templates/core/user_account_detail.jinja -#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja -#: counter/templates/counter/fragments/create_student_card.jinja -#: counter/templates/counter/last_ops.jinja -#: election/templates/election/election_detail.jinja -#: forum/templates/forum/macros.jinja -#: launderette/templates/launderette/launderette_admin.jinja -#: launderette/views.py pedagogy/templates/pedagogy/guide.jinja -#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja -#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja -#: trombi/templates/trombi/detail.jinja -#: trombi/templates/trombi/edit_profile.jinja -msgid "Delete" -msgstr "Supprimer" - -#: accounting/templates/accounting/bank_account_details.jinja club/views.py -#: core/views/user.py sas/templates/sas/picture.jinja -msgid "Infos" -msgstr "Infos" - -#: accounting/templates/accounting/bank_account_details.jinja -msgid "IBAN: " -msgstr "IBAN : " - -#: accounting/templates/accounting/bank_account_details.jinja -msgid "Number: " -msgstr "Numéro : " - -#: accounting/templates/accounting/bank_account_details.jinja -msgid "New club account" -msgstr "Nouveau compte club" - -#: accounting/templates/accounting/bank_account_details.jinja -#: accounting/templates/accounting/bank_account_list.jinja -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja club/views.py -#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja -#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja -#: core/templates/core/file.jinja core/templates/core/group_list.jinja -#: core/templates/core/page.jinja core/templates/core/user_tools.jinja -#: core/views/user.py counter/templates/counter/cash_summary_list.jinja -#: counter/templates/counter/counter_list.jinja -#: election/templates/election/election_detail.jinja -#: forum/templates/forum/macros.jinja -#: launderette/templates/launderette/launderette_list.jinja -#: pedagogy/templates/pedagogy/guide.jinja -#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja -#: trombi/templates/trombi/detail.jinja -#: trombi/templates/trombi/edit_profile.jinja -msgid "Edit" -msgstr "Éditer" - -#: accounting/templates/accounting/bank_account_list.jinja -msgid "Bank account list" -msgstr "Liste des comptes en banque" - -#: accounting/templates/accounting/bank_account_list.jinja -msgid "Manage simplified types" -msgstr "Gérer les types simplifiés" - -#: accounting/templates/accounting/bank_account_list.jinja -msgid "Manage accounting types" -msgstr "Gérer les types comptable" - -#: accounting/templates/accounting/bank_account_list.jinja -msgid "New bank account" -msgstr "Nouveau compte en banque" - -#: accounting/templates/accounting/bank_account_list.jinja -msgid "There is no accounts in this website." -msgstr "Il n'y a pas de comptes dans ce site web." - -#: accounting/templates/accounting/club_account_details.jinja -msgid "Club account:" -msgstr "Compte club : " - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/label_list.jinja -msgid "New label" -msgstr "Nouvelle étiquette" - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/label_list.jinja -msgid "Label list" -msgstr "Liste des étiquettes" - -#: accounting/templates/accounting/club_account_details.jinja -msgid "New journal" -msgstr "Nouveau classeur" - -#: accounting/templates/accounting/club_account_details.jinja -msgid "You can not create new journal while you still have one opened" -msgstr "Vous ne pouvez pas créer de journal tant qu'il y en a un d'ouvert" - -#: accounting/templates/accounting/club_account_details.jinja -#: launderette/templates/launderette/launderette_admin.jinja -msgid "Name" -msgstr "Nom" - -#: accounting/templates/accounting/club_account_details.jinja -#: com/templates/com/news_admin_list.jinja -msgid "Start" -msgstr "Début" - -#: accounting/templates/accounting/club_account_details.jinja -#: com/templates/com/news_admin_list.jinja -msgid "End" -msgstr "Fin" - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -#: core/templates/core/user_account_detail.jinja -#: counter/templates/counter/last_ops.jinja -#: counter/templates/counter/refilling_list.jinja -msgid "Amount" -msgstr "Montant" - -#: accounting/templates/accounting/club_account_details.jinja -msgid "Effective amount" -msgstr "Montant effectif" - -#: accounting/templates/accounting/club_account_details.jinja sith/settings.py -msgid "Closed" -msgstr "Fermé" - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -#: com/templates/com/mailing_admin.jinja -#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja -#: counter/templates/counter/refilling_list.jinja -msgid "Actions" -msgstr "Actions" - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -msgid "Yes" -msgstr "Oui" - -#: accounting/templates/accounting/club_account_details.jinja -#: accounting/templates/accounting/journal_details.jinja -msgid "No" -msgstr "Non" - -#: accounting/templates/accounting/club_account_details.jinja -#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja -#: core/templates/core/page.jinja -msgid "View" -msgstr "Voir" - -#: accounting/templates/accounting/co_list.jinja -#: accounting/templates/accounting/journal_details.jinja -#: core/templates/core/user_tools.jinja -msgid "Company list" -msgstr "Liste des entreprises" - -#: accounting/templates/accounting/co_list.jinja -msgid "Create new company" -msgstr "Nouvelle entreprise" - -#: accounting/templates/accounting/co_list.jinja -msgid "Companies" -msgstr "Entreprises" - -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/journal_statement_accounting.jinja -#: accounting/templates/accounting/journal_statement_nature.jinja -#: accounting/templates/accounting/journal_statement_person.jinja -msgid "General journal:" -msgstr "Classeur : " - -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/journal_statement_accounting.jinja -#: core/templates/core/user_account.jinja -#: core/templates/core/user_account_detail.jinja -#: counter/templates/counter/counter_click.jinja -msgid "Amount: " -msgstr "Montant : " - -#: accounting/templates/accounting/journal_details.jinja -#: accounting/templates/accounting/journal_statement_accounting.jinja -msgid "Effective amount: " -msgstr "Montant effectif: " - -#: accounting/templates/accounting/journal_details.jinja -msgid "Journal is closed, you can not create operation" -msgstr "Le classeur est fermé, vous ne pouvez pas créer d'opération" - -#: accounting/templates/accounting/journal_details.jinja -msgid "New operation" -msgstr "Nouvelle opération" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Nb" -msgstr "No" - -#: accounting/templates/accounting/journal_details.jinja -#: club/templates/club/club_sellings.jinja -#: core/templates/core/user_account_detail.jinja -#: counter/templates/counter/cash_summary_list.jinja -#: counter/templates/counter/last_ops.jinja -#: counter/templates/counter/refilling_list.jinja -#: rootplace/templates/rootplace/logs.jinja sas/forms.py -#: trombi/templates/trombi/user_profile.jinja -msgid "Date" -msgstr "Date" - -#: accounting/templates/accounting/journal_details.jinja -#: club/templates/club/club_sellings.jinja -#: core/templates/core/user_account_detail.jinja -#: counter/templates/counter/last_ops.jinja -#: rootplace/templates/rootplace/logs.jinja -msgid "Label" -msgstr "Étiquette" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Payment mode" -msgstr "Méthode de paiement" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Target" -msgstr "Cible" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Code" -msgstr "Code" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Nature" -msgstr "Nature" - -#: accounting/templates/accounting/journal_details.jinja -msgid "Done" -msgstr "Effectuées" - -#: accounting/templates/accounting/journal_details.jinja -#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py -#: pedagogy/templates/pedagogy/moderation.jinja -#: pedagogy/templates/pedagogy/uv_detail.jinja -#: trombi/templates/trombi/comment.jinja -#: trombi/templates/trombi/user_tools.jinja -msgid "Comment" -msgstr "Commentaire" - -#: accounting/templates/accounting/journal_details.jinja -msgid "File" -msgstr "Fichier" - -#: accounting/templates/accounting/journal_details.jinja -msgid "PDF" -msgstr "PDF" - -#: accounting/templates/accounting/journal_details.jinja -msgid "" -"Warning: this operation has no linked operation because the targeted club " -"account has no opened journal." -msgstr "" -"Attention: cette opération n'a pas d'opération liée parce qu'il n'y a pas de " -"classeur ouvert dans le compte club cible" - -#: accounting/templates/accounting/journal_details.jinja -#, python-format -msgid "" -"Open a journal in this club account, then save this " -"operation again to make the linked operation." -msgstr "" -"Ouvrez un classeur dans ce compte club, puis sauver " -"cette opération à nouveau pour créer l'opération liée." - -#: accounting/templates/accounting/journal_details.jinja -msgid "Generate" -msgstr "Générer" - -#: accounting/templates/accounting/journal_statement_accounting.jinja -msgid "Accounting statement: " -msgstr "Bilan comptable : " - -#: accounting/templates/accounting/journal_statement_accounting.jinja -#: rootplace/templates/rootplace/logs.jinja -msgid "Operation type" -msgstr "Type d'opération" - -#: accounting/templates/accounting/journal_statement_accounting.jinja -#: accounting/templates/accounting/journal_statement_nature.jinja -#: accounting/templates/accounting/journal_statement_person.jinja -#: counter/templates/counter/invoices_call.jinja -msgid "Sum" -msgstr "Somme" - -#: accounting/templates/accounting/journal_statement_nature.jinja -msgid "Nature of operation" -msgstr "Nature de l'opération" - -#: accounting/templates/accounting/journal_statement_nature.jinja -#: club/templates/club/club_sellings.jinja -#: counter/templates/counter/counter_main.jinja -msgid "Total: " -msgstr "Total : " - -#: accounting/templates/accounting/journal_statement_nature.jinja -msgid "Statement by nature: " -msgstr "Bilan par nature : " - -#: accounting/templates/accounting/journal_statement_person.jinja -msgid "Statement by person: " -msgstr "Bilan par personne : " - -#: accounting/templates/accounting/journal_statement_person.jinja -msgid "Target of the operation" -msgstr "Cible de l'opération" - -#: accounting/templates/accounting/label_list.jinja -msgid "Back to club account" -msgstr "Retour au compte club" - -#: accounting/templates/accounting/label_list.jinja -msgid "There is no label in this club account." -msgstr "Il n'y a pas d'étiquette dans ce compte club." - -#: accounting/templates/accounting/operation_edit.jinja -msgid "Edit operation" -msgstr "Éditer l'opération" - -#: accounting/templates/accounting/operation_edit.jinja -msgid "" -"Warning: if you select Account, the opposite operation will be " -"created in the target account. If you don't want that, select Club " -"instead of Account." -msgstr "" -"Attention : si vous sélectionnez Compte, l'opération inverse sera " -"créée dans le compte cible. Si vous ne le voulez pas, sélectionnez Club à la place de Compte." - -#: accounting/templates/accounting/operation_edit.jinja -msgid "Linked operation:" -msgstr "Opération liée : " - -#: accounting/templates/accounting/operation_edit.jinja -#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja -#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja -#: core/templates/core/create.jinja core/templates/core/edit.jinja -#: core/templates/core/file_edit.jinja core/templates/core/macros_pages.jinja -#: core/templates/core/page_prop.jinja -#: core/templates/core/user_godfathers.jinja -#: core/templates/core/user_godfathers_tree.jinja -#: core/templates/core/user_preferences.jinja -#: counter/templates/counter/cash_register_summary.jinja -#: forum/templates/forum/reply.jinja -#: subscription/templates/subscription/fragments/creation_form.jinja -#: trombi/templates/trombi/comment.jinja -#: trombi/templates/trombi/edit_profile.jinja -#: trombi/templates/trombi/user_tools.jinja -msgid "Save" -msgstr "Sauver" - -#: accounting/templates/accounting/refound_account.jinja accounting/views.py -msgid "Refound account" -msgstr "Remboursement de compte" - -#: accounting/templates/accounting/refound_account.jinja -msgid "Refound" -msgstr "Rembourser" - -#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja -msgid "Simplified type list" -msgstr "Liste des types simplifiés" - -#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja -msgid "Simplified types" -msgstr "Types simplifiés" - -#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja -msgid "New simplified type" -msgstr "Nouveau type simplifié" - -#: accounting/views.py -msgid "Journal" -msgstr "Classeur" - -#: accounting/views.py -msgid "Statement by nature" -msgstr "Bilan par nature" - -#: accounting/views.py -msgid "Statement by person" -msgstr "Bilan par personne" - -#: accounting/views.py -msgid "Accounting statement" -msgstr "Bilan comptable" - -#: accounting/views.py -msgid "Link this operation to the target account" -msgstr "Lier cette opération au compte cible" - -#: accounting/views.py -msgid "The target must be set." -msgstr "La cible doit être indiquée." - -#: accounting/views.py -msgid "The amount must be set." -msgstr "Le montant doit être indiqué." - -#: accounting/views.py -msgid "Operation" -msgstr "Opération" - -#: accounting/views.py -msgid "Financial proof: " -msgstr "Justificatif de libellé : " - -#: accounting/views.py -#, python-format -msgid "Club: %(club_name)s" -msgstr "Club : %(club_name)s" - -#: accounting/views.py -#, python-format -msgid "Label: %(op_label)s" -msgstr "Libellé : %(op_label)s" - -#: accounting/views.py -#, python-format -msgid "Date: %(date)s" -msgstr "Date : %(date)s" - -#: accounting/views.py -#, python-format -msgid "Amount: %(amount).2f €" -msgstr "Montant : %(amount).2f €" - -#: accounting/views.py -msgid "Debtor" -msgstr "Débiteur" - -#: accounting/views.py -msgid "Creditor" -msgstr "Créditeur" - -#: accounting/views.py -msgid "Comment:" -msgstr "Commentaire :" - -#: accounting/views.py -msgid "Signature:" -msgstr "Signature :" - -#: accounting/views.py -msgid "General statement" -msgstr "Bilan général" - -#: accounting/views.py -msgid "No label operations" -msgstr "Opérations sans étiquette" - -#: accounting/views.py -msgid "Refound this account" -msgstr "Rembourser ce compte" - #: antispam/forms.py msgid "Email domain is not allowed." msgstr "Le domaine de l'addresse e-mail n'est pas autorisé." @@ -881,21 +120,14 @@ msgstr "Vous devez choisir un rôle" msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" -#: club/models.py -msgid "unix name" -msgstr "nom unix" +#: club/models.py com/models.py counter/models.py forum/models.py +#: launderette/models.py +msgid "name" +msgstr "nom" #: club/models.py -msgid "" -"Enter a valid unix name. This value may contain only letters, numbers ./-/_ " -"characters." -msgstr "" -"Entrez un nom UNIX valide. Cette valeur peut contenir uniquement des " -"lettres, des nombres, et les caractères ./-/_" - -#: club/models.py -msgid "A club with that unix name already exists." -msgstr "Un club avec ce nom UNIX existe déjà." +msgid "slug name" +msgstr "nom slug" #: club/models.py msgid "logo" @@ -909,6 +141,14 @@ msgstr "actif" msgid "short description" msgstr "description courte" +#: club/models.py +msgid "" +"A summary of what your club does. This will be displayed on the club list " +"page." +msgstr "" +"Un résumé des activités des activités de votre club. Ceci sera affiché sur " +"la page de la liste des clubs." + #: club/models.py core/models.py msgid "address" msgstr "Adresse" @@ -926,6 +166,18 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgid "user" msgstr "utilisateur" +#: club/models.py com/models.py counter/models.py trombi/models.py +msgid "club" +msgstr "club" + +#: club/models.py counter/models.py election/models.py launderette/models.py +msgid "start date" +msgstr "date de début" + +#: club/models.py counter/models.py election/models.py +msgid "end date" +msgstr "date de fin" + #: club/models.py core/models.py election/models.py trombi/models.py msgid "role" msgstr "rôle" @@ -939,6 +191,17 @@ msgstr "description" msgid "past member" msgstr "ancien membre" +#: club/models.py club/templates/club/club_detail.jinja +#: com/templates/com/mailing_admin.jinja +#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja +#: core/templates/core/user_clubs.jinja +#: counter/templates/counter/invoices_call.jinja +#: trombi/templates/trombi/edit_profile.jinja +#: trombi/templates/trombi/export.jinja +#: trombi/templates/trombi/user_profile.jinja +msgid "Club" +msgstr "Club" + #: club/models.py msgid "Email address" msgstr "Adresse email" @@ -964,6 +227,14 @@ msgstr "Cette liste de diffusion existe déjà." msgid "Mailing" msgstr "Liste de diffusion" +#: club/models.py club/templates/club/club_members.jinja +#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja +#: counter/templates/counter/cash_summary_list.jinja +#: counter/templates/counter/stats.jinja +#: launderette/templates/launderette/launderette_admin.jinja +msgid "User" +msgstr "Utilisateur" + #: club/models.py msgid "At least user or email is required" msgstr "Au moins un utilisateur ou un email est nécessaire" @@ -988,7 +259,7 @@ msgstr "inactif" msgid "New club" msgstr "Nouveau club" -#: club/templates/club/club_list.jinja club/templates/club/stats.jinja +#: club/templates/club/club_list.jinja msgid "There is no club in this website." msgstr "Il n'y a pas de club dans ce site web." @@ -1059,7 +330,7 @@ msgstr "Suivant" msgid "Sales" msgstr "Ventes" -#: club/templates/club/club_sellings.jinja club/templates/club/stats.jinja +#: club/templates/club/club_sellings.jinja #: counter/templates/counter/cash_summary_list.jinja msgid "Show" msgstr "Montrer" @@ -1077,10 +348,25 @@ msgstr "Quantité : " msgid "units" msgstr "unités" +#: club/templates/club/club_sellings.jinja +#: counter/templates/counter/counter_main.jinja +msgid "Total: " +msgstr "Total : " + #: club/templates/club/club_sellings.jinja msgid "Benefit: " msgstr "Bénéfice : " +#: club/templates/club/club_sellings.jinja +#: core/templates/core/user_account_detail.jinja +#: counter/templates/counter/cash_summary_list.jinja +#: counter/templates/counter/last_ops.jinja +#: counter/templates/counter/refilling_list.jinja +#: rootplace/templates/rootplace/logs.jinja sas/forms.py +#: trombi/templates/trombi/user_profile.jinja +msgid "Date" +msgstr "Date" + #: club/templates/club/club_sellings.jinja #: core/templates/core/user_account_detail.jinja #: counter/templates/counter/last_ops.jinja @@ -1094,6 +380,13 @@ msgstr "Barman" msgid "Customer" msgstr "Client" +#: club/templates/club/club_sellings.jinja +#: core/templates/core/user_account_detail.jinja +#: counter/templates/counter/last_ops.jinja +#: rootplace/templates/rootplace/logs.jinja +msgid "Label" +msgstr "Étiquette" + #: club/templates/club/club_sellings.jinja #: core/templates/core/user_account_detail.jinja #: core/templates/core/user_stats.jinja @@ -1119,6 +412,29 @@ msgstr "Total" msgid "Payment method" msgstr "Méthode de paiement" +#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja +#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja +#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja +#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja +#: core/templates/core/file_detail.jinja +#: core/templates/core/file_moderation.jinja +#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja +#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja +#: core/templates/core/user_account_detail.jinja +#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja +#: counter/templates/counter/fragments/create_student_card.jinja +#: counter/templates/counter/last_ops.jinja +#: election/templates/election/election_detail.jinja +#: forum/templates/forum/macros.jinja +#: launderette/templates/launderette/launderette_admin.jinja +#: launderette/views.py pedagogy/templates/pedagogy/guide.jinja +#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja +#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja +#: trombi/templates/trombi/detail.jinja +#: trombi/templates/trombi/edit_profile.jinja +msgid "Delete" +msgstr "Supprimer" + #: club/templates/club/club_tools.jinja core/templates/core/user_tools.jinja msgid "Club tools" msgstr "Outils club" @@ -1152,14 +468,56 @@ msgstr "Affiches" msgid "Counters:" msgstr "Comptoirs : " -#: club/templates/club/club_tools.jinja -msgid "Accounting: " -msgstr "Comptabilité : " - #: club/templates/club/club_tools.jinja msgid "Manage launderettes" msgstr "Gestion des laveries" +#: club/templates/club/edit_club.jinja core/templates/core/edit.jinja +#, python-format +msgid "Edit %(name)s" +msgstr "Éditer %(name)s" + +#: club/templates/club/edit_club.jinja +msgid "Club properties" +msgstr "Propriétés du club" + +#: club/templates/club/edit_club.jinja +msgid "" +"The following form fields are linked to the core properties of a club. Only " +"admin users can see and edit them." +msgstr "" +"Les champs de formulaire suivants sont liées aux propriétés essentielles " +"d'un club. Seuls les administrateurs peuvent voir et modifier ceux-ci." + +#: club/templates/club/edit_club.jinja +msgid "Club informations" +msgstr "Informations du club" + +#: club/templates/club/edit_club.jinja +msgid "" +"The following form fields are linked to the basic description of a club. All " +"board members of this club can see and edit them." +msgstr "" +"Les champs de formulaire suivants sont liées à la description basique d'un " +"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci." + +#: club/templates/club/edit_club.jinja com/templates/com/news_edit.jinja +#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja +#: com/templates/com/weekmail.jinja core/templates/core/create.jinja +#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja +#: core/templates/core/macros_pages.jinja core/templates/core/page_prop.jinja +#: core/templates/core/user_godfathers.jinja +#: core/templates/core/user_godfathers_tree.jinja +#: core/templates/core/user_preferences.jinja +#: counter/templates/counter/cash_register_summary.jinja +#: forum/templates/forum/reply.jinja +#: subscription/templates/subscription/fragments/creation_form.jinja +#: trombi/templates/trombi/comment.jinja +#: trombi/templates/trombi/edit_profile.jinja +#: trombi/templates/trombi/user_tools.jinja +msgid "Save" +msgstr "Sauver" + #: club/templates/club/mailing.jinja msgid "Mailing lists" msgstr "Mailing listes" @@ -1218,9 +576,9 @@ msgstr "Créer une liste de diffusion" msgid "No page existing for this club" msgstr "Aucune page n'existe pour ce club" -#: club/templates/club/stats.jinja -msgid "Club stats" -msgstr "Statistiques du club" +#: club/views.py core/views/user.py sas/templates/sas/picture.jinja +msgid "Infos" +msgstr "Infos" #: club/views.py msgid "Members" @@ -1239,6 +597,23 @@ msgstr "Historique" msgid "Tools" msgstr "Outils" +#: club/views.py com/templates/com/news_admin_list.jinja +#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja +#: com/templates/com/weekmail.jinja core/templates/core/file.jinja +#: core/templates/core/group_list.jinja core/templates/core/page.jinja +#: core/templates/core/user_tools.jinja core/views/user.py +#: counter/templates/counter/cash_summary_list.jinja +#: counter/templates/counter/counter_list.jinja +#: election/templates/election/election_detail.jinja +#: forum/templates/forum/macros.jinja +#: launderette/templates/launderette/launderette_list.jinja +#: pedagogy/templates/pedagogy/guide.jinja +#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja +#: trombi/templates/trombi/detail.jinja +#: trombi/templates/trombi/edit_profile.jinja +msgid "Edit" +msgstr "Éditer" + #: club/views.py msgid "Edit club page" msgstr "Éditer la page de club" @@ -1255,10 +630,6 @@ msgstr "Listes de diffusion" msgid "Posters list" msgstr "Liste d'affiches" -#: club/views.py counter/templates/counter/counter_list.jinja -msgid "Props" -msgstr "Propriétés" - #: com/forms.py msgid "Format: 16:9 | Resolution: 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080" @@ -1448,6 +819,12 @@ msgstr "Nouvelle supprimée" msgid "Mailing lists administration" msgstr "Administration des mailing listes" +#: com/templates/com/mailing_admin.jinja +#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja +#: counter/templates/counter/refilling_list.jinja +msgid "Actions" +msgstr "Actions" + #: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja #: sas/templates/sas/picture.jinja @@ -1521,6 +898,11 @@ msgstr "Modérateur" msgid "Dates" msgstr "Dates" +#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja +#: core/templates/core/page.jinja +msgid "View" +msgstr "Voir" + #: com/templates/com/news_admin_list.jinja msgid "Unpublish" msgstr "Dépublier" @@ -1538,6 +920,14 @@ msgstr "Événements" msgid "Displayed events" msgstr "Événements affichés" +#: com/templates/com/news_admin_list.jinja +msgid "Start" +msgstr "Début" + +#: com/templates/com/news_admin_list.jinja +msgid "End" +msgstr "Fin" + #: com/templates/com/news_admin_list.jinja msgid "Events to moderate" msgstr "Événements à modérer" @@ -1920,6 +1310,10 @@ msgstr "Homme" msgid "Woman" msgstr "Femme" +#: core/models.py sith/settings.py +msgid "Other" +msgstr "Autre" + #: core/models.py msgid "pronouns" msgstr "pronoms" @@ -2020,6 +1414,10 @@ msgstr "signature du forum" msgid "second email address" msgstr "adresse email secondaire" +#: core/models.py +msgid "phone" +msgstr "téléphone" + #: core/models.py msgid "parent phone" msgstr "téléphone des parents" @@ -2136,6 +1534,10 @@ msgstr "type mime" msgid "size" msgstr "taille" +#: core/models.py counter/models.py eboutic/models.py forum/models.py +msgid "date" +msgstr "date" + #: core/models.py msgid "asked for removal" msgstr "retrait demandé" @@ -2234,6 +1636,10 @@ msgstr "type" msgid "viewed" msgstr "vue" +#: core/models.py counter/models.py +msgid "label" +msgstr "étiquette" + #: core/models.py msgid "operation type" msgstr "type d'opération" @@ -2407,11 +1813,9 @@ msgid "Delete confirmation" msgstr "Confirmation de suppression" #: core/templates/core/delete_confirm.jinja -#: core/templates/core/file_delete_confirm.jinja -#: counter/templates/counter/fragments/delete_student_card.jinja #, python-format -msgid "Are you sure you want to delete \"%(obj)s\"?" -msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" +msgid "Are you sure you want to delete \"%(name)s\"?" +msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?" #: core/templates/core/delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja @@ -2427,11 +1831,6 @@ msgstr "Confirmation" msgid "Cancel" msgstr "Annuler" -#: core/templates/core/edit.jinja -#, python-format -msgid "Edit %(name)s" -msgstr "Éditer %(name)s" - #: core/templates/core/file.jinja core/templates/core/file_list.jinja msgid "File list" msgstr "Liste de fichiers" @@ -2452,6 +1851,12 @@ msgstr "Mes fichiers" msgid "Prop" msgstr "Propriétés" +#: core/templates/core/file_delete_confirm.jinja +#: counter/templates/counter/fragments/delete_student_card.jinja +#, python-format +msgid "Are you sure you want to delete \"%(obj)s\"?" +msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" + #: core/templates/core/file_detail.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/picture.jinja msgid "Owner: " @@ -2837,6 +2242,7 @@ msgid "Users" msgstr "Utilisateurs" #: core/templates/core/search.jinja core/views/user.py +#: counter/templates/counter/product_list.jinja msgid "Clubs" msgstr "Clubs" @@ -2859,6 +2265,12 @@ msgstr "Compte de %(user_name)s" msgid "User account" msgstr "Compte utilisateur" +#: core/templates/core/user_account.jinja +#: core/templates/core/user_account_detail.jinja +#: counter/templates/counter/counter_click.jinja +msgid "Amount: " +msgstr "Montant : " + #: core/templates/core/user_account.jinja #: core/templates/core/user_account_detail.jinja msgid "Account purchases" @@ -2885,6 +2297,12 @@ msgstr "Etickets" msgid "User has no account" msgstr "L'utilisateur n'a pas de compte" +#: core/templates/core/user_account_detail.jinja +#: counter/templates/counter/last_ops.jinja +#: counter/templates/counter/refilling_list.jinja +msgid "Amount" +msgstr "Montant" + #: core/templates/core/user_account_detail.jinja msgid "Items" msgstr "Articles" @@ -3182,7 +2600,7 @@ msgid "Bans" msgstr "Bans" #: core/templates/core/user_tools.jinja counter/forms.py -#: counter/views/mixins.py +#: counter/templates/counter/product_list.jinja counter/views/mixins.py msgid "Counters" msgstr "Comptoirs" @@ -3198,6 +2616,10 @@ msgstr "Gestion des produits" msgid "Product types management" msgstr "Gestion des types de produit" +#: core/templates/core/user_tools.jinja +msgid "Returnable products management" +msgstr "Gestion des consignes" + #: core/templates/core/user_tools.jinja #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py msgid "Cash register summaries" @@ -3213,18 +2635,14 @@ msgstr "Appels à facture" msgid "Stats" msgstr "Stats" +#: core/templates/core/user_tools.jinja +msgid "Accounting" +msgstr "Comptabilité" + #: core/templates/core/user_tools.jinja msgid "Refound Account" msgstr "Rembourser un compte" -#: core/templates/core/user_tools.jinja -msgid "General accounting" -msgstr "Comptabilité générale" - -#: core/templates/core/user_tools.jinja -msgid "Club account: " -msgstr "Compte club : " - #: core/templates/core/user_tools.jinja msgid "Communication" msgstr "Communication" @@ -3413,8 +2831,8 @@ msgid "Pictures" msgstr "Photos" #: core/views/user.py -msgid "Galaxy" -msgstr "Galaxie" +msgid "Account" +msgstr "Compte" #: counter/apps.py sith/settings.py msgid "Check" @@ -3448,6 +2866,10 @@ msgstr "" "Décrivez le produit. Si c'est un click pour un évènement, donnez quelques " "détails dessus, comme la date (en incluant l'année)." +#: counter/forms.py +msgid "Refound this account" +msgstr "Rembourser ce compte" + #: counter/management/commands/dump_accounts.py msgid "Your AE account has been emptied" msgstr "Votre compte AE a été vidé" @@ -3461,8 +2883,8 @@ msgid "account id" msgstr "numéro de compte" #: counter/models.py -msgid "recorded product" -msgstr "produits consignés" +msgid "amount" +msgstr "montant" #: counter/models.py msgid "customer" @@ -3520,6 +2942,10 @@ msgstr "Mettre à True si le mail a reçu une erreur" msgid "The operation that emptied the account." msgstr "L'opération qui a vidé le compte." +#: counter/models.py pedagogy/models.py +msgid "comment" +msgstr "commentaire" + #: counter/models.py msgid "A text that will be shown on the eboutic." msgstr "Un texte qui sera affiché sur l'eboutic." @@ -3528,6 +2954,10 @@ msgstr "Un texte qui sera affiché sur l'eboutic." msgid "product type" msgstr "type du produit" +#: counter/models.py pedagogy/models.py +msgid "code" +msgstr "code" + #: counter/models.py msgid "purchase price" msgstr "prix d'achat" @@ -3596,6 +3026,10 @@ msgstr "vendeurs" msgid "token" msgstr "jeton" +#: counter/models.py subscription/models.py +msgid "payment method" +msgstr "méthode de paiement" + #: counter/models.py msgid "bank" msgstr "banque" @@ -3709,6 +3143,35 @@ msgstr "carte étudiante" msgid "student cards" msgstr "cartes étudiantes" +#: counter/models.py +msgid "returnable product" +msgstr "produit consigné" + +#: counter/models.py +msgid "returned product" +msgstr "produit déconsigné" + +#: counter/models.py +msgid "maximum returns" +msgstr "nombre de déconsignes maximum" + +#: counter/models.py +msgid "" +"The maximum number of items a customer can return without having actually " +"bought them." +msgstr "" +"Le nombre maximum d'articles qu'un client peut déconsigner sans les avoir " +"achetés." + +#: counter/models.py +msgid "returnable products" +msgstr "produits consignés" + +#: counter/models.py +msgid "The returnable product cannot be the same as the returned one" +msgstr "" +"Le produit consigné ne peut pas être le même que le produit de déconsigne" + #: counter/templates/counter/activity.jinja #, python-format msgid "%(counter_name)s activity" @@ -3754,6 +3217,14 @@ msgstr "Sommes théoriques" msgid "Emptied" msgstr "Coffre vidé" +#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py +#: pedagogy/templates/pedagogy/moderation.jinja +#: pedagogy/templates/pedagogy/uv_detail.jinja +#: trombi/templates/trombi/comment.jinja +#: trombi/templates/trombi/user_tools.jinja +msgid "Comment" +msgstr "Commentaire" + #: counter/templates/counter/cash_summary_list.jinja msgid "yes" msgstr "oui" @@ -3834,6 +3305,10 @@ msgstr "Liste des comptoirs" msgid "New counter" msgstr "Nouveau comptoir" +#: counter/templates/counter/counter_list.jinja +msgid "Props" +msgstr "Propriétés" + #: counter/templates/counter/counter_list.jinja #: counter/templates/counter/refilling_list.jinja msgid "Reloads list" @@ -3927,6 +3402,10 @@ msgstr "Choisir un autre mois : " msgid "CB Payments" msgstr "Payements en Carte Bancaire" +#: counter/templates/counter/invoices_call.jinja +msgid "Sum" +msgstr "Somme" + #: counter/templates/counter/last_ops.jinja #, python-format msgid "%(counter_name)s last operations" @@ -4076,6 +3555,26 @@ msgstr "Il n'y a pas de types de produit dans ce site web." msgid "Seller" msgstr "Vendeur" +#: counter/templates/counter/refound_account.jinja counter/views/admin.py +msgid "Refound account" +msgstr "Remboursement de compte" + +#: counter/templates/counter/refound_account.jinja +msgid "Refound" +msgstr "Rembourser" + +#: counter/templates/counter/returnable_list.jinja counter/views/mixins.py +msgid "Returnable products" +msgstr "Produits consignés" + +#: counter/templates/counter/returnable_list.jinja +msgid "New returnable product" +msgstr "Nouveau produit consignable" + +#: counter/templates/counter/returnable_list.jinja +msgid "Returned product" +msgstr "Produit déconsigné" + #: counter/templates/counter/stats.jinja #, python-format msgid "%(counter_name)s stats" @@ -4108,6 +3607,11 @@ msgstr "Temps" msgid "Top 100 barman %(counter_name)s (all semesters)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)" +#: counter/views/admin.py +#, python-format +msgid "returnable product : %(returnable)s -> %(returned)s" +msgstr "produit consigné : %(returnable)s -> %(returned)s" + #: counter/views/cash.py msgid "10 cents" msgstr "10 centimes" @@ -4161,12 +3665,20 @@ msgid "The selected product isn't available for this user" msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur" #: counter/views/click.py -msgid "Submmited basket is invalid" +msgid "Submitted basket is invalid" msgstr "Le panier envoyé est invalide" #: counter/views/click.py -msgid "This user have reached his recording limit" -msgstr "Cet utilisateur a atteint sa limite de déconsigne" +msgid "Duplicated product entries." +msgstr "Saisie de produit dupliquée" + +#: counter/views/click.py +#, python-format +msgid "" +"This user have reached his recording limit for the following products : %s" +msgstr "" +"Cet utilisateur a atteint sa limite de déconsigne pour les produits " +"suivants : %s" #: counter/views/eticket.py msgid "people(s)" @@ -4242,6 +3754,10 @@ msgstr "id du type du produit" msgid "basket" msgstr "panier" +#: eboutic/models.py +msgid "invoice" +msgstr "facture" + #: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_makecommand.jinja msgid "Current account amount: " @@ -4753,6 +4269,10 @@ msgstr "Nouvelle machine" msgid "Type" msgstr "Type" +#: launderette/templates/launderette/launderette_admin.jinja +msgid "Name" +msgstr "Nom" + #: launderette/templates/launderette/launderette_book.jinja msgid "Choose" msgstr "Choisir" @@ -5120,6 +4640,10 @@ msgstr "Utilisateur qui sera conservé" msgid "User that will be deleted" msgstr "Utilisateur qui sera supprimé" +#: rootplace/forms.py +msgid "You cannot merge two identical users." +msgstr "Vous ne pouvez pas fusionner deux utilisateurs identiques." + #: rootplace/forms.py msgid "User to be selected" msgstr "Utilisateur à sélectionner" @@ -5141,6 +4665,10 @@ msgstr "" "erreur 500), essayez en utilisant l'utilitaire en ligne de commande. " "Utilisez ./manage.py delete_user_messages ID." +#: rootplace/templates/rootplace/logs.jinja +msgid "Operation type" +msgstr "Type d'opération" + #: rootplace/templates/rootplace/logs.jinja msgid "Operator" msgstr "Opérateur" @@ -5219,15 +4747,15 @@ msgstr "SAS" msgid "Albums" msgstr "Albums" -#: sas/templates/sas/album.jinja -msgid "Download album" -msgstr "Télécharger l'album" - #: sas/templates/sas/album.jinja sas/templates/sas/macros.jinja #: sas/templates/sas/user_pictures.jinja msgid "To be moderated" msgstr "A modérer" +#: sas/templates/sas/album.jinja +msgid "Download album" +msgstr "Télécharger l'album" + #: sas/templates/sas/album.jinja msgid "Upload" msgstr "Envoyer" @@ -5418,6 +4946,10 @@ msgstr "ST" msgid "EXT" msgstr "EXT" +#: sith/settings.py +msgid "Closed" +msgstr "Fermé" + #: sith/settings.py msgid "Autumn" msgstr "Automne" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index c222636a..9b967354 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-25 16:10+0100\n" +"POT-Creation-Date: 2025-03-28 13:52+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -34,6 +34,7 @@ msgid "Delete" msgstr "Supprimer" #: com/static/bundled/com/components/moderation-alert-index.ts +#, javascript-format msgid "" "This event will take place every week for %s weeks. If you publish or delete " "this event, it will also be published (or deleted) for the following weeks." diff --git a/manage.py b/manage.py index 56271706..101696e2 100755 --- a/manage.py +++ b/manage.py @@ -13,13 +13,25 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # - +import atexit +import logging import os import sys +from django.utils.autoreload import DJANGO_AUTORELOAD_ENV + +from sith.composer import start_composer, stop_composer +from sith.settings import PROCFILE_SERVICE + if __name__ == "__main__": + logging.basicConfig(encoding="utf-8", level=logging.INFO) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sith.settings") from django.core.management import execute_from_command_line + if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and PROCFILE_SERVICE is not None: + start_composer(PROCFILE_SERVICE) + _ = atexit.register(stop_composer, procfile=PROCFILE_SERVICE) + execute_from_command_line(sys.argv) diff --git a/openapi-csrf.ts b/openapi-csrf.ts new file mode 100644 index 00000000..5d1cf84b --- /dev/null +++ b/openapi-csrf.ts @@ -0,0 +1,9 @@ +import Cookies from "js-cookie"; +import type { CreateClientConfig } from "#openapi"; + +export const createClientConfig: CreateClientConfig = (config) => ({ + ...config, + headers: { + "X-CSRFToken": Cookies.get("csrftoken"), + }, +}); diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index d583ffee..f69aaeb1 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -4,7 +4,16 @@ import { defineConfig } from "@hey-api/openapi-ts"; // biome-ignore lint/style/noDefaultExport: needed for openapi-ts export default defineConfig({ - client: "@hey-api/client-fetch", input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"), - output: resolve(__dirname, "./staticfiles/generated/openapi"), + output: { + path: resolve(__dirname, "./staticfiles/generated/openapi/client"), + }, + plugins: [ + { + name: "@hey-api/client-fetch", + baseUrl: false, + runtimeConfigPath: "./openapi-csrf.ts", + exportFromIndex: true, + }, + ], }); diff --git a/package-lock.json b/package-lock.json index 012e48e7..d6d496e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,13 @@ "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/icalendar": "^6.1.15", "@fullcalendar/list": "^6.1.15", - "@hey-api/client-fetch": "^0.6.0", + "@hey-api/client-fetch": "^0.8.2", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", "alpinejs": "^3.14.7", "chart.js": "^4.4.4", + "country-flag-emoji-polyfill": "^0.1.8", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", "cytoscape-klay": "^3.1.4", @@ -31,6 +32,7 @@ "htmx.org": "^2.0.3", "jquery": "^3.7.1", "jquery-ui": "^1.14.0", + "js-cookie": "^3.0.5", "native-file-system-adapter": "^3.0.1", "three": "^0.172.0", "three-spritetext": "^1.9.0", @@ -40,7 +42,7 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4", "@biomejs/biome": "1.9.4", - "@hey-api/openapi-ts": "^0.61.3", + "@hey-api/openapi-ts": "^0.64.0", "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/jquery": "^3.5.31", @@ -2207,18 +2209,18 @@ } }, "node_modules/@hey-api/client-fetch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.6.0.tgz", - "integrity": "sha512-FlhFsVeH8RxJe/nq8xUzxNbiOpe+GadxlD2pfvDyOyLdCTU4o/LRv46ZVWstaW7DgF4nxhI328chy3+AulwVXw==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz", + "integrity": "sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/hey-api" } }, "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.1.tgz", - "integrity": "sha512-dBt0A7op9kf4BcK++x6HBYDmvCvnJUZEGe5QytghPFHnMXPyKwDKomwL/v5e9ERk6E0e1GzL/e/y6pWUso9zrQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz", + "integrity": "sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2234,13 +2236,13 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.61.3", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.61.3.tgz", - "integrity": "sha512-Ls9MBRa5+vg7UHw6fIcfdgcCyZ9vKtRw63nWxwF9zjJIPlzVOZO6xKuzGmDc6o0Pb6XCdTz6lPV5hcV0R4b/ag==", + "version": "0.64.8", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.8.tgz", + "integrity": "sha512-ytPt/k+ecK7zcpxVocPWzD1bKn98a+9WDK8eJITvbOEkvYsWlozAPO63tQg+65Qpl2pr37025fEo8YcX+DPhBQ==", "dev": true, "license": "MIT", "dependencies": { - "@hey-api/json-schema-ref-parser": "1.0.1", + "@hey-api/json-schema-ref-parser": "1.0.2", "c12": "2.0.1", "commander": "13.0.0", "handlebars": "4.7.8" @@ -2249,7 +2251,7 @@ "openapi-ts": "bin/index.cjs" }, "engines": { - "node": "^18.20.5 || ^20.11.1 || >=22.11.0" + "node": "^18.18.0 || ^20.9.0 || >=22.10.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -3378,6 +3380,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/country-flag-emoji-polyfill": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/country-flag-emoji-polyfill/-/country-flag-emoji-polyfill-0.1.8.tgz", + "integrity": "sha512-Mbah52sADS3gshUYhK5142gtUuJpHYOXlXtLFI3Ly4RqgkmPMvhX9kMZSTqDM8P7UqtSW99eHKFphhQSGXA3Cg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4295,6 +4303,15 @@ "jquery": ">=1.12.0 <5.0.0" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 93494819..83b65145 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "compile": "vite build --mode production", "compile-dev": "vite build --mode development", - "serve": "vite build --mode development --watch", + "serve": "vite build --mode development --watch --minify false", + "openapi": "openapi-ts", "analyse-dev": "vite-bundle-visualizer --mode development", "analyse-prod": "vite-bundle-visualizer --mode production", "check": "biome check --write" @@ -16,7 +17,7 @@ "license": "GPL-3.0-only", "sideEffects": [".css"], "imports": { - "#openapi": "./staticfiles/generated/openapi/index.ts", + "#openapi": "./staticfiles/generated/openapi/client/index.ts", "#core:*": "./core/static/bundled/*", "#pedagogy:*": "./pedagogy/static/bundled/*", "#counter:*": "./counter/static/bundled/*", @@ -26,7 +27,7 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4", "@biomejs/biome": "1.9.4", - "@hey-api/openapi-ts": "^0.61.3", + "@hey-api/openapi-ts": "^0.64.0", "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/jquery": "^3.5.31", @@ -42,12 +43,13 @@ "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/icalendar": "^6.1.15", "@fullcalendar/list": "^6.1.15", - "@hey-api/client-fetch": "^0.6.0", + "@hey-api/client-fetch": "^0.8.2", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", "alpinejs": "^3.14.7", "chart.js": "^4.4.4", + "country-flag-emoji-polyfill": "^0.1.8", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", "cytoscape-klay": "^3.1.4", @@ -57,6 +59,7 @@ "htmx.org": "^2.0.3", "jquery": "^3.7.1", "jquery-ui": "^1.14.0", + "js-cookie": "^3.0.5", "native-file-system-adapter": "^3.0.1", "three": "^0.172.0", "three-spritetext": "^1.9.0", diff --git a/pyproject.toml b/pyproject.toml index a4d16abc..78c1b9c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,34 +19,36 @@ authors = [ license = {text = "GPL-3.0-only"} requires-python = "<4.0,>=3.12" dependencies = [ - "Django<5.0.0,>=4.2.17", + "django<5.0.0,>=4.2.20", "django-ninja<2.0.0,>=1.3.0", - "django-ninja-extra<1.0.0,>=0.21.8", - "Pillow<12.0.0,>=11.0.0", - "mistune<4.0.0,>=3.0.2", + "django-ninja-extra<1.0.0,>=0.22.4", + "Pillow<12.0.0,>=11.1.0", + "mistune<4.0.0,>=3.1.2", "django-jinja<3.0.0,>=2.11.0", - "cryptography<45.0.0,>=44.0.0", + "cryptography<45.0.0,>=44.0.2", "django-phonenumber-field<9.0.0,>=8.0.0", - "phonenumbers<9.0.0,>=8.13.52", - "reportlab<5.0.0,>=4.2.5", + "phonenumbers>=9.0.0,<10.0.0", + "reportlab<5.0.0,>=4.3.1", "django-haystack<4.0.0,>=3.3.0", "xapian-haystack<4.0.0,>=3.1.0", "libsass<1.0.0,>=0.23.0", "django-ordered-model<4.0.0,>=3.7.4", - "django-simple-captcha<1.0.0,>=0.6.0", + "django-simple-captcha<1.0.0,>=0.6.2", "python-dateutil<3.0.0.0,>=2.9.0.post0", - "sentry-sdk<3.0.0,>=2.19.2", - "Jinja2<4.0.0,>=3.1.4", + "sentry-sdk<3.0.0,>=2.22.0", + "jinja2<4.0.0,>=3.1.6", "django-countries<8.0.0,>=7.6.1", "dict2xml<2.0.0,>=1.7.6", "Sphinx<6,>=5", "tomli<3.0.0,>=2.2.1", "django-honeypot<2.0.0,>=1.2.1", - "pydantic-extra-types<3.0.0,>=2.10.1", - "ical<9.0.0,>=8.3.0", + "pydantic-extra-types<3.0.0,>=2.10.2", + "ical<9.0.0,>=8.3.1", "redis[hiredis]<6.0.0,>=5.2.0", "environs[django]<15.0.0,>=14.1.0", "requests>=2.32.3", + "honcho>=2.0.0", + "psutil>=7.0.0", ] [project.urls] @@ -59,26 +61,28 @@ prod = [ ] dev = [ "django-debug-toolbar<5.0.0,>=4.4.6", - "ipython<9.0.0,>=8.30.0", - "pre-commit<5.0.0,>=4.0.1", - "ruff<1.0.0,>=0.8.3", + "ipython<10.0.0,>=9.0.2", + "pre-commit<5.0.0,>=4.1.0", + "ruff<1.0.0,>=0.9.10", "djhtml<4.0.0,>=3.0.7", - "faker<34.0.0,>=33.1.0", - "rjsmin<2.0.0,>=1.2.3", + "faker<38.0.0,>=37.0.0", + "rjsmin<2.0.0,>=1.2.4", ] tests = [ "freezegun<2.0.0,>=1.5.1", - "pytest<9.0.0,>=8.3.4", + "pytest<9.0.0,>=8.3.5", "pytest-cov<7.0.0,>=6.0.0", - "pytest-django<5.0.0,>=4.9.0", - "model-bakery<2.0.0,>=1.20.0", + "pytest-django<5.0.0,>=4.10.0", + "model-bakery<2.0.0,>=1.20.4", + "beautifulsoup4>=4.13.3,<5", + "lxml>=5.3.1,<6", ] docs = [ "mkdocs<2.0.0,>=1.6.1", - "mkdocs-material<10.0.0,>=9.5.47", - "mkdocstrings<1.0.0,>=0.27.0", - "mkdocstrings-python<2.0.0,>=1.12.2", - "mkdocs-include-markdown-plugin<8.0.0,>=7.1.2", + "mkdocs-material<10.0.0,>=9.6.7", + "mkdocstrings<1.0.0,>=0.28.3", + "mkdocstrings-python<2.0.0,>=1.16.3", + "mkdocs-include-markdown-plugin<8.0.0,>=7.1.5", ] [tool.uv] @@ -124,6 +128,13 @@ ignore = [ [tool.ruff.lint.pydocstyle] convention = "google" +[build-system] # A build system is needed to register a pytest plugin +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.entry-points.pytest11] +sith = "sith.pytest" + [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "sith.settings" python_files = ["tests.py", "test_*.py", "*_tests.py"] diff --git a/rootplace/forms.py b/rootplace/forms.py index 5e7f8e94..850238ac 100644 --- a/rootplace/forms.py +++ b/rootplace/forms.py @@ -1,9 +1,10 @@ from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.models import User, UserBan from core.views.forms import FutureDateTimeField, SelectDateTime -from core.views.widgets.select import AutoCompleteSelectUser +from core.views.widgets.ajax_select import AutoCompleteSelectUser class MergeForm(forms.Form): @@ -22,6 +23,16 @@ class MergeForm(forms.Form): queryset=User.objects.all(), ) + def clean(self): + cleaned_data = super().clean() + user1 = cleaned_data.get("user1") + user2 = cleaned_data.get("user2") + + if user1.id == user2.id: + raise ValidationError(_("You cannot merge two identical users.")) + + return cleaned_data + class SelectUserForm(forms.Form): user = forms.ModelChoiceField( diff --git a/rootplace/tests/test_merge_users.py b/rootplace/tests/test_merge_users.py index ad66fdd8..baaa8ca9 100644 --- a/rootplace/tests/test_merge_users.py +++ b/rootplace/tests/test_merge_users.py @@ -18,17 +18,19 @@ from django.conf import settings from django.test import TestCase from django.urls import reverse from django.utils.timezone import localtime, now +from model_bakery import baker from club.models import Club from core.models import Group, User from counter.models import Counter, Customer, Product, Refilling, Selling +from rootplace.forms import MergeForm from subscription.models import Subscription class TestMergeUser(TestCase): @classmethod def setUpTestData(cls): - cls.ae = Club.objects.get(unix_name="ae") + cls.club = baker.make(Club) cls.eboutic = Counter.objects.get(name="Eboutic") cls.barbar = Product.objects.get(code="BARB") cls.barbar.selling_price = 2 @@ -79,6 +81,15 @@ class TestMergeUser(TestCase): sas_admin.id, } + def test_identical_accounts(self): + form = MergeForm(data={"user1": self.to_keep.id, "user2": self.to_keep.id}) + assert not form.is_valid() + assert "__all__" in form.errors + assert ( + "Vous ne pouvez pas fusionner deux utilisateurs identiques." + in form.errors["__all__"] + ) + def test_both_subscribers_and_with_account(self): Customer(user=self.to_keep, account_id="11000l", amount=0).save() Customer(user=self.to_delete, account_id="12000m", amount=0).save() @@ -97,7 +108,7 @@ class TestMergeUser(TestCase): Selling( label="barbar", counter=self.eboutic, - club=self.ae, + club=self.club, product=self.barbar, customer=self.to_keep.customer, seller=self.root, @@ -108,7 +119,7 @@ class TestMergeUser(TestCase): Selling( label="barbar", counter=self.eboutic, - club=self.ae, + club=self.club, product=self.barbar, customer=self.to_delete.customer, seller=self.root, @@ -180,7 +191,7 @@ class TestMergeUser(TestCase): Selling( label="barbar", counter=self.eboutic, - club=self.ae, + club=self.club, product=self.barbar, customer=self.to_delete.customer, seller=self.root, @@ -208,7 +219,7 @@ class TestMergeUser(TestCase): Selling( label="barbar", counter=self.eboutic, - club=self.ae, + club=self.club, product=self.barbar, customer=self.to_keep.customer, seller=self.root, diff --git a/sas/api.py b/sas/api.py index 11355de5..d9e2ad2e 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,6 +1,3 @@ -from typing import Annotated - -from annotated_types import MinLen from django.conf import settings from django.db.models import F from django.urls import reverse @@ -16,6 +13,8 @@ from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoo from core.models import Notification, User from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( + AlbumAutocompleteSchema, + AlbumFilterSchema, AlbumSchema, IdentifiedUserSchema, ModerationRequestSchema, @@ -31,11 +30,30 @@ class AlbumController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[AlbumSchema], + permissions=[IsAuthenticated], + url_name="search-album", + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def fetch_album(self, filters: Query[AlbumFilterSchema]): + """General-purpose album search.""" + return filters.filter(Album.objects.viewable_by(self.context.request.user)) + + @route.get( + "/autocomplete-search", + response=PaginatedResponseSchema[AlbumAutocompleteSchema], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) - def search_album(self, search: Annotated[str, MinLen(1)]): - return Album.objects.filter(name__icontains=search) + def autocomplete_album(self, filters: Query[AlbumFilterSchema]): + """Search route to use exclusively on autocomplete input fields. + + This route is separated from `GET /sas/album/search` because + getting the path of an album may need an absurd amount of db queries. + + If you don't need the path of the albums, + do NOT use this route. + """ + return filters.filter(Album.objects.viewable_by(self.context.request.user)) @api_controller("/sas/picture") diff --git a/sas/forms.py b/sas/forms.py index 926fe6ca..d987aaf1 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -6,9 +6,9 @@ from django.utils.translation import gettext_lazy as _ from core.models import User from core.views import MultipleImageField from core.views.forms import SelectDate -from core.views.widgets.select import AutoCompleteSelectMultipleGroup +from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup from sas.models import Album, Picture, PictureModerationRequest -from sas.widgets.select import AutoCompleteSelectAlbum +from sas.widgets.ajax_select import AutoCompleteSelectAlbum class SASForm(forms.Form): diff --git a/sas/models.py b/sas/models.py index bf87786d..e2b8867a 100644 --- a/sas/models.py +++ b/sas/models.py @@ -23,7 +23,7 @@ from typing import ClassVar, Self from django.conf import settings from django.core.cache import cache from django.db import models -from django.db.models import Exists, OuterRef +from django.db.models import Exists, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -73,7 +73,7 @@ class PictureQuerySet(models.QuerySet): if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): return self.all() if user.was_subscribed: - return self.filter(is_moderated=True) + return self.filter(Q(is_moderated=True) | Q(owner=user)) return self.filter(people__user_id=user.id, is_moderated=True) @@ -187,7 +187,7 @@ class AlbumQuerySet(models.QuerySet): if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): return self.all() if user.was_subscribed: - return self.filter(is_moderated=True) + return self.filter(Q(is_moderated=True) | Q(owner=user)) # known bug : if all children of an album are also albums # then this album is excluded, even if one of the sub-albums should be visible. # The fs-like navigation is likely to be half-broken for non-subscribers, diff --git a/sas/schemas.py b/sas/schemas.py index d606219b..76eb908a 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -1,6 +1,8 @@ from datetime import datetime from pathlib import Path +from typing import Annotated +from annotated_types import MinLen from django.urls import reverse from ninja import FilterSchema, ModelSchema, Schema from pydantic import Field, NonNegativeInt @@ -9,7 +11,37 @@ from core.schemas import SimpleUserSchema, UserProfileSchema from sas.models import Album, Picture, PictureModerationRequest +class AlbumFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") + before_date: datetime | None = Field(None, q="event_date__lte") + after_date: datetime | None = Field(None, q="event_date__gte") + parent_id: int | None = Field(None, q="parent_id") + + class AlbumSchema(ModelSchema): + class Meta: + model = Album + fields = ["id", "name", "is_moderated"] + + thumbnail: str | None + sas_url: str + + @staticmethod + def resolve_thumbnail(obj: Album) -> str | None: + # Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file` + # Don't ask me why. + if not obj.file: + return None + return obj.get_download_url() + + @staticmethod + def resolve_sas_url(obj: Album) -> str: + return obj.get_absolute_url() + + +class AlbumAutocompleteSchema(ModelSchema): + """Schema to use on album autocomplete input field.""" + class Meta: model = Album fields = ["id", "name"] diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index ff0976d9..6dda1ce9 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -1,18 +1,25 @@ import { paginated } from "#core:utils/api"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; import { + type AlbumFetchAlbumData, + type AlbumSchema, type PictureSchema, type PicturesFetchPicturesData, + albumFetchAlbum, picturesFetchPictures, } from "#openapi"; -interface AlbumConfig { +interface AlbumPicturesConfig { albumId: number; maxPageSize: number; } +interface SubAlbumsConfig { + parentId: number; +} + document.addEventListener("alpine:init", () => { - Alpine.data("pictures", (config: AlbumConfig) => ({ + Alpine.data("pictures", (config: AlbumPicturesConfig) => ({ pictures: [] as PictureSchema[], page: Number.parseInt(initialUrlParams.get("page")) || 1, pushstate: History.Push /* Used to avoid pushing a state on a back action */, @@ -23,6 +30,7 @@ document.addEventListener("alpine:init", () => { this.$watch("page", () => { updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate); this.pushstate = History.Push; + this.fetchPictures(); }); window.addEventListener("popstate", () => { @@ -30,7 +38,6 @@ document.addEventListener("alpine:init", () => { this.page = Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1; }); - this.config = config; }, getPage(page: number) { @@ -43,11 +50,9 @@ document.addEventListener("alpine:init", () => { async fetchPictures() { this.loading = true; this.pictures = await paginated(picturesFetchPictures, { - query: { - // biome-ignore lint/style/useNamingConvention: API is in snake_case - album_id: config.albumId, - } as PicturesFetchPicturesData["query"], - }); + // biome-ignore lint/style/useNamingConvention: API is in snake_case + query: { album_id: config.albumId }, + } as PicturesFetchPicturesData); this.loading = false; }, @@ -55,4 +60,22 @@ document.addEventListener("alpine:init", () => { return Math.ceil(this.pictures.length / config.maxPageSize); }, })); + + Alpine.data("albums", (config: SubAlbumsConfig) => ({ + albums: [] as AlbumSchema[], + loading: false, + + async init() { + await this.fetchAlbums(); + }, + + async fetchAlbums() { + this.loading = true; + this.albums = await paginated(albumFetchAlbum, { + // biome-ignore lint/style/useNamingConvention: API is snake_case + query: { parent_id: config.parentId }, + } as AlbumFetchAlbumData); + this.loading = false; + }, + })); }); diff --git a/sas/static/bundled/sas/components/ajax-select-index.ts b/sas/static/bundled/sas/components/ajax-select-index.ts index 5b811f52..aa640556 100644 --- a/sas/static/bundled/sas/components/ajax-select-index.ts +++ b/sas/static/bundled/sas/components/ajax-select-index.ts @@ -2,7 +2,7 @@ import { AjaxSelect } from "#core:core/components/ajax-select-base"; import { registerComponent } from "#core:utils/web-components"; import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; -import { type AlbumSchema, albumSearchAlbum } from "#openapi"; +import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; @registerComponent("album-ajax-select") export class AlbumAjaxSelect extends AjaxSelect { @@ -11,20 +11,20 @@ export class AlbumAjaxSelect extends AjaxSelect { protected searchField = ["path", "name"]; protected async search(query: string): Promise { - const resp = await albumSearchAlbum({ query: { search: query } }); + const resp = await albumAutocompleteAlbum({ query: { search: query } }); if (resp.data) { return resp.data.results; } return []; } - protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { + protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { return `
    ${sanitize(item.path)}
    `; } - protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { + protected renderItem(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { return `${sanitize(item.path)}`; } } diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index d3cd83ae..a18dede5 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -17,11 +17,9 @@ document.addEventListener("alpine:init", () => { async init() { this.pictures = await paginated(picturesFetchPictures, { - query: { - // biome-ignore lint/style/useNamingConvention: from python api - users_identified: [config.userId], - } as PicturesFetchPicturesData["query"], - }); + // biome-ignore lint/style/useNamingConvention: from python api + query: { users_identified: [config.userId] }, + } as PicturesFetchPicturesData); this.albums = this.pictures.reduce( (acc: Record, picture: PictureSchema) => { diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index faa9505a..59718b26 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -4,9 +4,9 @@ import { History } from "#core:utils/history"; import type TomSelect from "tom-select"; import { type IdentifiedUserSchema, + type ModerationRequestSchema, type PictureSchema, type PicturesFetchIdentificationsResponse, - type PicturesFetchModerationRequestsResponse, type PicturesFetchPicturesData, type UserProfileSchema, picturesDeletePicture, @@ -30,7 +30,7 @@ class PictureWithIdentifications { id: number; // biome-ignore lint/style/useNamingConvention: api is in snake_case compressed_url: string; - moderationRequests: PicturesFetchModerationRequestsResponse = null; + moderationRequests: ModerationRequestSchema[] = null; constructor(picture: PictureSchema) { Object.assign(this, picture); @@ -156,9 +156,6 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { * The select2 component used to identify users **/ selector: undefined, - /** - * true if the page is in a loading state, else false - **/ /** * Error message when a moderation operation fails **/ diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 172e81ab..6c2cbcf7 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -53,32 +53,43 @@ {% endif %} {% endif %} - {% if children_albums|length > 0 %} -

    {% trans %}Albums{% endtrans %}

    -
    - {% for a in children_albums %} - {{ display_album(a, is_sas_admin) }} - {% endfor %} + {% if show_albums %} +
    +

    {% trans %}Albums{% endtrans %}

    +
    + +
    - -
    {% endif %} -
    - - {{ download_button(_("Download album")) }} - +

    {% trans %}Pictures{% endtrans %}

    +
    + {{ download_button(_("Download album")) }}