Merge pull request #1057 from ae-utbm/taiste

Honcho, more product filters, sas improvements, club refactor, accounting removal, dcons and more
This commit is contained in:
thomas girod 2025-04-04 12:08:36 +02:00 committed by GitHub
commit 29c1142537
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
153 changed files with 2997 additions and 5068 deletions

View File

@ -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=sqlite:///db.sqlite3
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith #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

View File

@ -9,6 +9,11 @@ runs:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
- name: Install Redis
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
with: with:

View File

@ -10,6 +10,7 @@ on:
env: env:
SECRET_KEY: notTheRealOne SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3 DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
jobs: jobs:
pre-commit: pre-commit:
@ -30,7 +31,7 @@ jobs:
strategy: strategy:
fail-fast: false # don't interrupt the other test processes fail-fast: false # don't interrupt the other test processes
matrix: matrix:
pytest-mark: [slow, not slow] pytest-mark: [not slow]
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4

9
.gitignore vendored
View File

@ -18,7 +18,14 @@ sith/search_indexes/
.coverage .coverage
coverage_report/ coverage_report/
node_modules/ node_modules/
.env
*.pid
# compiled documentation # compiled documentation
site/ site/
.env
### Redis ###
# Ignore redis binary dump (dump.rdb) files
*.rdb

View File

@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.8.3 rev: v0.9.10
hooks: hooks:
- id: ruff # just check the code, and print the errors - id: ruff # just check the code, and print the errors
- id: ruff # actually fix the fixable errors, but print nothing - id: ruff # actually fix the fixable errors, but print nothing

1
Procfile.service Normal file
View File

@ -0,0 +1 @@
redis: redis-server --port $REDIS_PORT

1
Procfile.static Normal file
View File

@ -0,0 +1 @@
bundler: npm run serve

View File

@ -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)

View File

@ -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()

View File

@ -4,7 +4,7 @@ import django.core.validators
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import accounting.models import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -142,7 +142,7 @@ class Migration(migrations.Migration):
), ),
( (
"amount", "amount",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, decimal_places=2,
default=0, default=0,
verbose_name="amount", verbose_name="amount",
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
), ),
( (
"effective_amount", "effective_amount",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, decimal_places=2,
default=0, default=0,
verbose_name="effective_amount", verbose_name="effective_amount",
@ -176,7 +176,7 @@ class Migration(migrations.Migration):
("number", models.IntegerField(verbose_name="number")), ("number", models.IntegerField(verbose_name="number")),
( (
"amount", "amount",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount" decimal_places=2, max_digits=12, verbose_name="amount"
), ),
), ),

View File

@ -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"),
]

View File

@ -12,509 +12,3 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # 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)

View File

@ -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"]

View File

@ -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<TomOption[]> {
const resp = await accountingSearchClubAccount({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@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<TomOption[]> {
const resp = await accountingSearchCompany({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CompanySchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CompanySchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -1,27 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Accounting type list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{% trans %}Accounting types{% endtrans %}
</p>
<hr>
<p><a href="{{ url('accounting:type_new') }}">{% trans %}New accounting type{% endtrans %}</a></p>
{% if accountingtype_list %}
<h3>{% trans %}Accounting type list{% endtrans %}</h3>
<ul>
{% for a in accountingtype_list %}
<li><a href="{{ url('accounting:type_edit', type_id=a.id) }}">{{ a }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no types in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Bank account: {% endtrans %}{{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{{ object.name }}
</p>
<hr>
<h2>{% trans %}Bank account: {% endtrans %}{{ object.name }}</h2>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and not object.club_accounts.exists() %}
<a href="{{ url('accounting:bank_delete', b_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
<h4>{% trans %}Infos{% endtrans %}</h4>
<ul>
<li><strong>{% trans %}IBAN: {% endtrans %}</strong>{{ object.iban }}</li>
<li><strong>{% trans %}Number: {% endtrans %}</strong>{{ object.number }}</li>
</ul>
<p><a href="{{ url('accounting:club_new') }}?parent={{ object.id }}">{% trans %}New club account{% endtrans %}</a></p>
<ul>
{% for c in object.club_accounts.all() %}
<li><a href="{{ url('accounting:club_details', c_account_id=c.id) }}">{{ c }}</a>
- <a href="{{ url('accounting:club_edit', c_account_id=c.id) }}">{% trans %}Edit{% endtrans %}</a>
{% if c.journals.count() == 0 %}
- <a href="{{ url('accounting:club_delete', c_account_id=c.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@ -1,33 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Bank account list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<h4>
{% trans %}Accounting{% endtrans %}
</h4>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:simple_type_list') }}">{% trans %}Manage simplified types{% endtrans %}</a></p>
<p><a href="{{ url('accounting:type_list') }}">{% trans %}Manage accounting types{% endtrans %}</a></p>
<p><a href="{{ url('accounting:bank_new') }}">{% trans %}New bank account{% endtrans %}</a></p>
{% endif %}
{% if bankaccount_list %}
<h3>{% trans %}Bank account list{% endtrans %}</h3>
<ul>
{% for a in object_list %}
<li><a href="{{ url('accounting:bank_details', b_account_id=a.id) }}">{{ a }}</a>
- <a href="{{ url('accounting:bank_edit', b_account_id=a.id) }}">{% trans %}Edit{% endtrans %}</a>
</li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no accounts in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,68 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Club account:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
{{ object }}
</p>
<hr>
<h2>{% trans %}Club account:{% endtrans %} {{ object.name }}</h2>
{% if user.is_root and not object.journals.exists() %}
<a href="{{ url('accounting:club_delete', c_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
{% endif %}
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.id) }}">{% trans %}Label list{% endtrans %}</a></p>
{% if not object.has_open_journal() %}
<p><a href="{{ url('accounting:journal_new') }}?parent={{ object.id }}">{% trans %}New journal{% endtrans %}</a></p>
{% else %}
<p>{% trans %}You can not create new journal while you still have one opened{% endtrans %}</p>
{% endif %}
<table>
<thead>
<tr>
<td>{% trans %}Name{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Amount{% endtrans %}</td>
<td>{% trans %}Effective amount{% endtrans %}</td>
<td>{% trans %}Closed{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for j in object.journals.all() %}
<tr>
<td>{{ j.name }}</td>
<td>{{ j.start_date }}</td>
{% if j.end_date %}
<td>{{ j.end_date }}</td>
{% else %}
<td> - </td>
{% endif %}
<td>{{ j.amount }} €</td>
<td>{{ j.effective_amount }} €</td>
{% if j.closed %}
<td>{% trans %}Yes{% endtrans %}</td>
{% else %}
<td>{% trans %}No{% endtrans %}</td>
{% endif %}
<td> <a href="{{ url('accounting:journal_details', j_id=j.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('accounting:journal_edit', j_id=j.id) }}">{% trans %}Edit{% endtrans %}</a>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and j.operations.count() == 0 %}
<a href="{{ url('accounting:journal_delete', j_id=j.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,30 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Company list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
{% if user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
%}
<p><a href="{{ url('accounting:co_new') }}">{% trans %}Create new company{% endtrans %}</a></p>
{% endif %}
<br/>
<table>
<thead>
<tr>
<td>{% trans %}Companies{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for o in object_list %}
<tr>
<td><a href="{{ url('accounting:co_edit', co_id=o.id) }}">{{ o.get_display_name() }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,103 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
{{ object.name }}
</p>
<hr>
<h2>{% trans %}General journal:{% endtrans %} {{ object.name }}</h2>
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.club_account.id }}">{% trans %}New label{% endtrans %}</a></p>
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.club_account.id) }}">{% trans %}Label list{% endtrans %}</a></p>
<p><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></p>
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ object.amount }} € -
<strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ object.effective_amount }} €</p>
{% if object.closed %}
<p>{% trans %}Journal is closed, you can not create operation{% endtrans %}</p>
{% else %}
<p><a href="{{ url('accounting:op_new', j_id=object.id) }}">{% trans %}New operation{% endtrans %}</a></p>
</br>
{% endif %}
<div class="journal-table">
<table>
<thead>
<tr>
<td>{% trans %}Nb{% endtrans %}</td>
<td>{% trans %}Date{% endtrans %}</td>
<td>{% trans %}Label{% endtrans %}</td>
<td>{% trans %}Amount{% endtrans %}</td>
<td>{% trans %}Payment mode{% endtrans %}</td>
<td>{% trans %}Target{% endtrans %}</td>
<td>{% trans %}Code{% endtrans %}</td>
<td>{% trans %}Nature{% endtrans %}</td>
<td>{% trans %}Done{% endtrans %}</td>
<td>{% trans %}Comment{% endtrans %}</td>
<td>{% trans %}File{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
<td>{% trans %}PDF{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for o in object.operations.all() %}
<tr>
<td>{{ o.number }}</td>
<td>{{ o.date }}</td>
<td>{{ o.label or "" }}</td>
{% if o.accounting_type.movement_type == "DEBIT" %}
<td class="neg-amount">&nbsp;{{ o.amount }}&nbsp;€</td>
{% else %}
<td class="pos-amount">&nbsp;{{ o.amount }}&nbsp;€</td>
{% endif %}
<td>{{ o.get_mode_display() }}</td>
{% if o.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% else %}
<td><a href="{{ o.target.get_absolute_url() }}">{{ o.target.get_display_name() }}</a></td>
{% endif %}
<td>{{ o.accounting_type.code }}</td>
<td>{{ o.accounting_type.label }}</td>
{% if o.done %}
<td>{% trans %}Yes{% endtrans %}</td>
{% else %}
<td>{% trans %}No{% endtrans %}</td>
{% endif %}
<td>{{ o.remark }}
{% if not o.linked_operation and o.target_type == "ACCOUNT" and not o.target.has_open_journal() %}
<p><strong>
{% trans %}Warning: this operation has no linked operation because the targeted club account has no opened journal.{% endtrans %}
</strong></p>
<p><strong>
{% trans url=o.target.get_absolute_url() %}Open a journal in <a href="{{ url }}">this club account</a>, then save this operation again to make the linked operation.{% endtrans %}
</strong></p>
{% endif %}
</td>
{% if o.invoice %}
<td><a href="{{ url('core:download', file_id=o.invoice.id) }}">{{ o.invoice.name }}</a></td>
{% else %}
<td>-</td>
{% endif %}
<td>
{%
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 %}
<a href="{{ url('accounting:op_edit', op_id=o.id) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% endif %}
</td>
<td><a href="{{ url('accounting:op_pdf', op_id=o.id) }}">{% trans %}Generate{% endtrans %}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -1,33 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<h3>{% trans %}Accounting statement: {% endtrans %} {{ object.name }}</h3>
<table>
<thead>
<tr>
<td>{% trans %}Operation type{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in statement.items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ "%.2f" % object.amount }} €</p>
<p><strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ "%.2f" %object.effective_amount }} €</p>
</div>
{% endblock %}

View File

@ -1,57 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% macro display_tables(dict) %}
<div id="accounting">
<h6>{% trans %}Credit{% endtrans %}</h6>
<table>
<thead>
<tr>
<td>{% trans %}Nature of operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in dict['CREDIT'].items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['CREDIT_sum'] }}
<h6>{% trans %}Debit{% endtrans %}</h6>
<table>
<thead>
<tr>
<td>{% trans %}Nature of operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in dict['DEBIT'].items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['DEBIT_sum'] }}
{% endmacro %}
{% block content %}
<h3>{% trans %}Statement by nature: {% endtrans %} {{ object.name }}</h3>
{% for k,v in statement.items() %}
<h4 style="background: lightblue; padding: 4px;">{{ k }} : {{ "%.2f" % (v['CREDIT_sum'] - v['DEBIT_sum']) }}</h4>
{{ display_tables(v) }}
<hr>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,68 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<h3>{% trans %}Statement by person: {% endtrans %} {{ object.name }}</h3>
<h4>{% trans %}Credit{% endtrans %}</h4>
<table>
<thead>
<tr>
<td>{% trans %}Target of the operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for key in credit_statement.keys() %}
<tr>
{% if key.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% elif key %}
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
{% else %}
<td></td>
{% endif %}
<td>{{ "%.2f" % credit_statement[key] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Total : {{ "%.2f" % total_credit }}</p>
<h4>{% trans %}Debit{% endtrans %}</h4>
<table>
<thead>
<tr>
<td>{% trans %}Target of the operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for key in debit_statement.keys() %}
<tr>
{% if key.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% elif key %}
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
{% else %}
<td></td>
{% endif %}
<td>{{ "%.2f" % debit_statement[key] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Total : {{ "%.2f" % total_debit }}</p>
</div>
{% endblock %}

View File

@ -1,36 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Label list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{{ object }}</a>
</p>
<hr>
<p><a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{% trans %}Back to club account{% endtrans %}</a></p>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
{% endif %}
{% if object.labels.all() %}
<h3>{% trans %}Label list{% endtrans %}</h3>
<ul>
{% for l in object.labels.all() %}
<li><a href="{{ url('accounting:label_edit', label_id=l.id) }}">{{ l }}</a>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
-
<a href="{{ url('accounting:label_delete', label_id=l.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no label in this club account.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,123 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Edit operation{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
<a href="{{ url('accounting:journal_details', j_id=object.id) }}">{{ object.name }}</a> >
{% trans %}Edit operation{% endtrans %}
</p>
<hr>
<h2>{% trans %}Edit operation{% endtrans %}</h2>
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.journal }}
{{ form.target_id }}
<p>{{ form.amount.errors }}<label for="{{ form.amount.name }}">{{ form.amount.label }}</label> {{ form.amount }}</p>
<p>{{ form.remark.errors }}<label for="{{ form.remark.name }}">{{ form.remark.label }}</label> {{ form.remark }}</p>
<br />
<strong>{% trans %}Warning: if you select <em>Account</em>, the opposite operation will be created in the target account. If you don't want that, select <em>Club</em> instead of <em>Account</em>.{% endtrans %}</strong>
<p>{{ form.target_type.errors }}<label for="{{ form.target_type.name }}">{{ form.target_type.label }}</label> {{ form.target_type }}</p>
{{ form.user }}
{{ form.club }}
{{ form.club_account }}
{{ form.company }}
{{ form.target_label }}
<span id="id_need_link_full"><label>{{ form.need_link.label }}</label> {{ form.need_link }}</span>
<p>{{ form.date.errors }}<label for="{{ form.date.name }}">{{ form.date.label }}</label> {{ form.date }}</p>
<p>{{ form.mode.errors }}<label for="{{ form.mode.name }}">{{ form.mode.label }}</label> {{ form.mode }}</p>
<p>{{ form.cheque_number.errors }}<label for="{{ form.cheque_number.name }}">{{ form.cheque_number.label }}</label> {{
form.cheque_number }}</p>
<p>{{ form.invoice.errors }}<label for="{{ form.invoice.name }}">{{ form.invoice.label }}</label> {{ form.invoice }}</p>
<p>{{ form.simpleaccounting_type.errors }}<label for="{{ form.simpleaccounting_type.name }}">{{
form.simpleaccounting_type.label }}</label> {{ form.simpleaccounting_type }}</p>
<p>{{ form.accounting_type.errors }}<label for="{{ form.accounting_type.name }}">{{ form.accounting_type.label }}</label> {{
form.accounting_type }}</p>
<p>{{ form.label.errors }}<label for="{{ form.label.name }}">{{ form.label.label }}</label> {{ form.label }}</p>
<p>{{ form.done.errors }}<label for="{{ form.done.name }}">{{ form.done.label }}</label> {{ form.done }}</p>
{% if form.instance.linked_operation %}
{% set obj = form.instance.linked_operation %}
<p><strong>{% trans %}Linked operation:{% endtrans %}</strong><br>
<a href="{{ url('accounting:bank_details', b_account_id=obj.journal.club_account.bank_account.id) }}">
{{obj.journal.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=obj.journal.club_account.id) }}">{{ obj.journal.club_account }}</a> >
<a href="{{ url('accounting:journal_details', j_id=obj.journal.id) }}">{{ obj.journal }}</a> >
{{ obj.number }}
</p>
{% endif %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$( function() {
var target_type = $('#id_target_type');
var user = $('user-ajax-select');
var club = $('club-ajax-select');
var club_account = $('club-account-ajax-select');
var company = $('company-ajax-select');
var other = $('#id_target_label');
var need_link = $('#id_need_link_full');
function update_targets () {
if (target_type.val() == "USER") {
console.log(user);
user.show();
club.hide();
club_account.hide();
company.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "ACCOUNT") {
club_account.show();
need_link.show();
user.hide();
club.hide();
company.hide();
other.hide();
} else if (target_type.val() == "CLUB") {
club.show();
user.hide();
club_account.hide();
company.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "COMPANY") {
company.show();
user.hide();
club_account.hide();
club.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "OTHER") {
other.show();
user.hide();
club.hide();
club_account.hide();
company.hide();
need_link.hide();
} else {
company.hide();
user.hide();
club_account.hide();
club.hide();
other.hide();
need_link.hide();
}
}
update_targets();
target_type.change(update_targets);
} );
</script>
</div>
{% endblock %}

View File

@ -1,27 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Simplified type list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{% trans %}Simplified types{% endtrans %}
</p>
<hr>
<p><a href="{{ url('accounting:simple_type_new') }}">{% trans %}New simplified type{% endtrans %}</a></p>
{% if simplifiedaccountingtype_list %}
<h3>{% trans %}Simplified type list{% endtrans %}</h3>
<ul>
{% for a in simplifiedaccountingtype_list %}
<li><a href="{{ url('accounting:simple_type_edit', type_id=a.id) }}">{{ a }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no types in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -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 '<form action="" method="post">' 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 '<form action="" method="post">' 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 "<td>M\\xc3\\xa9thode de paiement</td>" 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 "<td>M\xc3\xa9thode de paiement</td>" 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("<td>Le fantome de la nuit</td>" 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 "<td>Le fantome de l&#39;aurore</td>" 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(
"""<td><a href="/user/1/">S&#39; Kia</a></td><td>3.00</td>""", content
)
self.assertInHTML(
"""<td><a href="/user/1/">S&#39; Kia</a></td><td>823.00</td>""", 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(
"""
<tr>
<td>443 - Crédit - Ce code n&#39;existe pas</td>
<td>3.00</td>
</tr>""",
response.content.decode(),
)
self.assertContains(
response,
"""
<p><strong>Montant : </strong>-5504.30 </p>
<p><strong>Montant effectif: </strong>-5504.30 </p>""",
)

View File

@ -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/<int:type_id>/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/<int:type_id>/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/<int:b_account_id>/",
BankAccountDetailView.as_view(),
name="bank_details",
),
path(
"bank/<int:b_account_id>/edit/",
BankAccountEditView.as_view(),
name="bank_edit",
),
path(
"bank/<int:b_account_id>/delete/",
BankAccountDeleteView.as_view(),
name="bank_delete",
),
# Club accounts
path("club/create/", ClubAccountCreateView.as_view(), name="club_new"),
path(
"club/<int:c_account_id>/",
ClubAccountDetailView.as_view(),
name="club_details",
),
path(
"club/<int:c_account_id>/edit/",
ClubAccountEditView.as_view(),
name="club_edit",
),
path(
"club/<int:c_account_id>/delete/",
ClubAccountDeleteView.as_view(),
name="club_delete",
),
# Journals
path("journal/create/", JournalCreateView.as_view(), name="journal_new"),
path(
"journal/<int:j_id>/",
JournalDetailView.as_view(),
name="journal_details",
),
path(
"journal/<int:j_id>/edit/",
JournalEditView.as_view(),
name="journal_edit",
),
path(
"journal/<int:j_id>/delete/",
JournalDeleteView.as_view(),
name="journal_delete",
),
path(
"journal/<int:j_id>/statement/nature/",
JournalNatureStatementView.as_view(),
name="journal_nature_statement",
),
path(
"journal/<int:j_id>/statement/person/",
JournalPersonStatementView.as_view(),
name="journal_person_statement",
),
path(
"journal/<int:j_id>/statement/accounting/",
JournalAccountingStatementView.as_view(),
name="journal_accounting_statement",
),
# Operations
path(
"operation/create/<int:j_id>/",
OperationCreateView.as_view(),
name="op_new",
),
path("operation/<int:op_id>/", OperationEditView.as_view(), name="op_edit"),
path("operation/<int:op_id>/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/<int:co_id>/", CompanyEditView.as_view(), name="co_edit"),
# Labels
path("label/new/", LabelCreateView.as_view(), name="label_new"),
path(
"label/<int:clubaccount_id>/",
LabelListView.as_view(),
name="label_list",
),
path("label/<int:label_id>/edit/", LabelEditView.as_view(), name="label_edit"),
path(
"label/<int:label_id>/delete/",
LabelDeleteView.as_view(),
name="label_delete",
),
# User account
path("refound/account/", RefoundAccountView.as_view(), name="refound_account"),
]

View File

@ -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()

View File

@ -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

View File

@ -19,8 +19,8 @@ from club.models import Club, Membership
@admin.register(Club) @admin.register(Club)
class ClubAdmin(admin.ModelAdmin): class ClubAdmin(admin.ModelAdmin):
list_display = ("name", "unix_name", "parent", "is_active") list_display = ("name", "slug_name", "parent", "is_active")
search_fields = ("name", "unix_name") search_fields = ("name", "slug_name")
autocomplete_fields = ( autocomplete_fields = (
"parent", "parent",
"board_group", "board_group",

View File

@ -29,18 +29,25 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime 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 from counter.models import Counter
class ClubEditForm(forms.ModelForm): class ClubEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta: class Meta:
model = Club model = Club
fields = ["address", "logo", "short_description"] fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) class ClubAdminEditForm(ClubEditForm):
self.fields["short_description"].widget = forms.Textarea() admin_fields = ["name", "parent", "is_active"]
class Meta(ClubEditForm.Meta):
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
class MailingForm(forms.Form): class MailingForm(forms.Form):

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import club.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("club", "0010_auto_20170912_2028")] dependencies = [("club", "0010_auto_20170912_2028")]
@ -15,7 +14,7 @@ class Migration(migrations.Migration):
name="owner_group", name="owner_group",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, 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", related_name="owned_club",
to="core.Group", to="core.Group",
), ),

View File

@ -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",
),
),
]

View File

@ -26,7 +26,6 @@ from __future__ import annotations
from typing import Iterable, Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core import validators
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User 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): class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization.""" """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"), unique=True, max_length=64)
name = models.CharField(_("name"), max_length=64)
parent = models.ForeignKey( parent = models.ForeignKey(
"Club", related_name="children", null=True, blank=True, on_delete=models.CASCADE "Club", related_name="children", null=True, blank=True, on_delete=models.CASCADE
) )
unix_name = models.CharField( slug_name = models.SlugField(
_("unix name"), _("slug name"), max_length=30, unique=True, editable=False
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."
),
) )
], logo = ResizedImageField(
error_messages={"unique": _("A club with that unix name already exists.")}, upload_to="club_logos",
) verbose_name=_("logo"),
logo = models.ImageField( null=True,
upload_to="club_logos", verbose_name=_("logo"), null=True, blank=True blank=True,
force_format="WEBP",
height=200,
width=200,
) )
is_active = models.BooleanField(_("is active"), default=True) is_active = models.BooleanField(_("is active"), default=True)
short_description = models.CharField( 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) address = models.CharField(_("address"), max_length=254)
home = models.OneToOneField( home = models.OneToOneField(
@ -88,7 +82,7 @@ class Club(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
page = models.OneToOneField( 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( members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT Group, related_name="club", on_delete=models.PROTECT
@ -98,7 +92,7 @@ class Club(models.Model):
) )
class Meta: class Meta:
ordering = ["name", "unix_name"] ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
@ -106,10 +100,12 @@ class Club(models.Model):
@transaction.atomic() @transaction.atomic()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
creation = self._state.adding creation = self._state.adding
if (slug := slugify(self.name)[:30]) != self.slug_name:
self.slug_name = slug
if not creation: if not creation:
db_club = Club.objects.get(id=self.id) db_club = Club.objects.get(id=self.id)
if self.unix_name != db_club.unix_name: if self.name != db_club.name:
self.home.name = self.unix_name self.home.name = self.slug_name
self.home.save() self.home.save()
if self.name != db_club.name: if self.name != db_club.name:
self.board_group.name = f"{self.name} - Bureau" self.board_group.name = f"{self.name} - Bureau"
@ -123,11 +119,9 @@ class Club(models.Model):
self.members_group = Group.objects.create( self.members_group = Group.objects.create(
name=f"{self.name} - Membres", is_manually_manageable=False name=f"{self.name} - Membres", is_manually_manageable=False
) )
super().save(*args, **kwargs)
if creation:
self.make_home() self.make_home()
self.make_page() self.make_page()
cache.set(f"sith_club_{self.unix_name}", self) super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("club:club_view", kwargs={"club_id": self.id}) return reverse("club:club_view", kwargs={"club_id": self.id})
@ -155,41 +149,30 @@ class Club(models.Model):
def make_home(self) -> None: def make_home(self) -> None:
if self.home: if self.home:
return return
home_root = SithFile.objects.filter(parent=None, name="clubs").first() home_root = SithFile.objects.get(parent=None, name="clubs")
root = User.objects.filter(username="root").first() root = User.objects.get(id=settings.SITH_ROOT_USER_ID)
if home_root and root: self.home = SithFile.objects.create(
home = SithFile(parent=home_root, name=self.unix_name, owner=root) parent=home_root, name=self.slug_name, owner=root
home.save() )
self.home = home
self.save()
def make_page(self) -> None: def make_page(self) -> None:
root = User.objects.filter(username="root").first() page_name = self.slug_name
if not self.page: if not self.page_id:
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() # Club.page is a OneToOneField, so if we are inside this condition
if root and club_root: # then self._meta.state.adding is True.
public = Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first() club_root = Page.objects.get(name=settings.SITH_CLUB_ROOT_PAGE)
p = Page(name=self.unix_name) public = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
p.parent = club_root p = Page(name=page_name, parent=club_root)
p.save(force_lock=True) p.save(force_lock=True)
if public:
p.view_groups.add(public) p.view_groups.add(public)
p.save(force_lock=True) if self.parent and self.parent.page_id:
if self.parent and self.parent.page: p.parent_id = self.parent.page_id
p.parent = self.parent.page
self.page = p self.page = p
self.save() return
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() 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.parent = self.parent.page
self.page.save(force_lock=True) self.page.save(force_lock=True)
@ -197,7 +180,6 @@ class Club(models.Model):
# Invalidate the cache of this club and of its memberships # Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"): for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}") cache.delete(f"membership_{self.id}_{membership.user.id}")
cache.delete(f"sith_club_{self.unix_name}")
self.board_group.delete() self.board_group.delete()
self.members_group.delete() self.members_group.delete()
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)

View File

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.unix_name }}"></div> <div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %} {% endif %}
{% if page_revision %} {% if page_revision %}
{{ page_revision|markdown }} {{ page_revision|markdown }}

View File

@ -16,7 +16,7 @@
</ul> </ul>
<h4>{% trans %}Counters:{% endtrans %}</h4> <h4>{% trans %}Counters:{% endtrans %}</h4>
<ul> <ul>
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %} {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
{% for l in Launderette.objects.all() %} {% for l in Launderette.objects.all() %}
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li> <li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
{% endfor %} {% endfor %}
@ -29,15 +29,7 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</ul> </ul>
{% if object.club_account.exists() %} {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
<h4>{% trans %}Accounting: {% endtrans %}</h4>
<ul>
{% for ca in object.club_account.all() %}
<li><a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li> <li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
{% endif %} {% endif %}
</div> </div>

View File

@ -0,0 +1,54 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
<form action="" method="post" enctype="multipart/form-data">
{% 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 #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the core properties of a club.
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="required margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
{{ field.errors }}
{{ field.label_tag() }}
{{ field }}
</div>
{# 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 %}
</fieldset>
<h3>{% trans %}Club informations{% endtrans %}</h3>
<p class="helptext">
{% 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 %}
</p>
{% endif %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock content %}

View File

@ -1,49 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Club stats{% endtrans %}
{% endblock %}
{% block content %}
{% if club_list %}
<h3>{% trans %}Club stats{% endtrans %}</h3>
<form action="" method="GET">
{% csrf_token %}
<p>
<select name="branch">
{% for b in settings.SITH_PROFILE_DEPARTMENTS %}
<option value="{{ b[0] }}">{{ b[0] }}</option>
{% endfor %}
</select>
</p>
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
</form>
<table>
<thead>
<tr>
<td>Club</td>
<td>Member number</td>
<td>Old member number</td>
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td>{{ c.get_display_name() }}</td>
<td>{{ members.filter(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
<td>{{ members.exclude(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@ -14,20 +14,21 @@
# #
from datetime import timedelta from datetime import timedelta
import pytest
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.forms import MailingForm from club.forms import MailingForm
from club.models import Club, Mailing, Membership from club.models import Club, Mailing, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
from sith.settings import SITH_BAR_MANAGER, SITH_MAIN_CLUB_ID
class TestClub(TestCase): class TestClub(TestCase):
@ -64,12 +65,8 @@ class TestClub(TestCase):
# not subscribed # not subscribed
cls.public = User.objects.get(username="public") cls.public = User.objects.get(username="public")
cls.ae = Club.objects.filter(pk=SITH_MAIN_CLUB_ID)[0] cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = Club.objects.create( cls.club = baker.make(Club)
name="Fake Club",
unix_name="fake-club",
address="5 rue de la République, 90000 Belfort",
)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30) a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1) yesterday = now() - timedelta(days=1)
@ -265,7 +262,7 @@ class TestClubModel(TestClub):
for membership in memberships.select_related("user"): for membership in memberships.select_related("user"):
user = membership.user user = membership.user
expected_html += ( expected_html += (
f"<tr><td><a href=\"{reverse('core:user_profile', args=[user.id])}\">" f'<tr><td><a href="{reverse("core:user_profile", args=[user.id])}">'
f"{user.get_display_name()}</a></td>" f"{user.get_display_name()}</a></td>"
f"<td>{settings.SITH_CLUB_ROLES[membership.role]}</td>" f"<td>{settings.SITH_CLUB_ROLES[membership.role]}</td>"
f"<td>{membership.description}</td>" f"<td>{membership.description}</td>"
@ -579,13 +576,11 @@ class TestMailingForm(TestCase):
cls.krophil = User.objects.get(username="krophil") cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity") cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root") cls.root = User.objects.get(username="root")
cls.bdf = Club.objects.get(unix_name=SITH_BAR_MANAGER["unix_name"]) cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.bdf.id}) cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
def setUp(self):
Membership( Membership(
user=self.rbatsbak, user=cls.rbatsbak,
club=self.bdf, club=cls.club,
start_date=timezone.now(), start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save() ).save()
@ -894,13 +889,43 @@ class TestClubSellingView(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.ae = Club.objects.get(unix_name="ae") cls.club = baker.make(Club)
cls.skia = User.objects.get(username="skia") cls.admin = baker.make(User, is_superuser=True)
def test_page_not_internal_error(self): def test_page_not_internal_error(self):
"""Test that the page does not return and internal error.""" """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( 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 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

View File

@ -26,7 +26,6 @@ from django.urls import path
from club.views import ( from club.views import (
ClubCreateView, ClubCreateView,
ClubEditPropView,
ClubEditView, ClubEditView,
ClubListView, ClubListView,
ClubMailingView, ClubMailingView,
@ -37,7 +36,6 @@ from club.views import (
ClubRevView, ClubRevView,
ClubSellingCSVView, ClubSellingCSVView,
ClubSellingView, ClubSellingView,
ClubStatView,
ClubToolsView, ClubToolsView,
ClubView, ClubView,
MailingAutoGenerationView, MailingAutoGenerationView,
@ -54,7 +52,6 @@ from club.views import (
urlpatterns = [ urlpatterns = [
path("", ClubListView.as_view(), name="club_list"), path("", ClubListView.as_view(), name="club_list"),
path("new/", ClubCreateView.as_view(), name="club_new"), path("new/", ClubCreateView.as_view(), name="club_new"),
path("stats/", ClubStatView.as_view(), name="club_stats"),
path("<int:club_id>/", ClubView.as_view(), name="club_view"), path("<int:club_id>/", ClubView.as_view(), name="club_view"),
path( path(
"<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev" "<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
@ -72,7 +69,6 @@ urlpatterns = [
path( path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" "<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
), ),
path("<int:club_id>/prop/", ClubEditPropView.as_view(), name="club_prop"),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"), path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"), path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
path( path(

View File

@ -39,10 +39,16 @@ from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _t from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _ 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 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 club.models import Club, Mailing, MailingSubscription, Membership
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
@ -50,12 +56,7 @@ from com.views import (
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import ( from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
@ -78,23 +79,23 @@ class ClubTabsMixin(TabedViewMixin):
} }
] ]
if self.request.user.can_view(self.object): if self.request.user.can_view(self.object):
tab_list.append( tab_list.extend(
[
{ {
"url": reverse( "url": reverse(
"club:club_members", kwargs={"club_id": self.object.id} "club:club_members", kwargs={"club_id": self.object.id}
), ),
"slug": "members", "slug": "members",
"name": _("Members"), "name": _("Members"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id} "club:club_old_members", kwargs={"club_id": self.object.id}
), ),
"slug": "elderlies", "slug": "elderlies",
"name": _("Old members"), "name": _("Old members"),
} },
]
) )
if self.object.page: if self.object.page:
tab_list.append( tab_list.append(
@ -107,21 +108,23 @@ class ClubTabsMixin(TabedViewMixin):
} }
) )
if self.request.user.can_edit(self.object): if self.request.user.can_edit(self.object):
tab_list.append( tab_list.extend(
[
{ {
"url": reverse("club:tools", kwargs={"club_id": self.object.id}), "url": reverse(
"club:tools", kwargs={"club_id": self.object.id}
),
"slug": "tools", "slug": "tools",
"name": _("Tools"), "name": _("Tools"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"club:club_edit", kwargs={"club_id": self.object.id} "club:club_edit", kwargs={"club_id": self.object.id}
), ),
"slug": "edit", "slug": "edit",
"name": _("Edit"), "name": _("Edit"),
} },
]
) )
if self.object.page and self.request.user.can_edit(self.object.page): if self.object.page and self.request.user.can_edit(self.object.page):
tab_list.append( tab_list.append(
@ -134,40 +137,30 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Edit club page"), "name": _("Edit club page"),
} }
) )
tab_list.append( tab_list.extend(
[
{ {
"url": reverse( "url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id} "club:club_sellings", kwargs={"club_id": self.object.id}
), ),
"slug": "sellings", "slug": "sellings",
"name": _("Sellings"), "name": _("Sellings"),
} },
)
tab_list.append(
{ {
"url": reverse("club:mailing", kwargs={"club_id": self.object.id}), "url": reverse(
"club:mailing", kwargs={"club_id": self.object.id}
),
"slug": "mailing", "slug": "mailing",
"name": _("Mailing list"), "name": _("Mailing list"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id} "club:poster_list", kwargs={"club_id": self.object.id}
), ),
"slug": "posters", "slug": "posters",
"name": _("Posters list"), "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"),
}
) )
return tab_list return tab_list
@ -189,8 +182,11 @@ class ClubView(ClubTabsMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
if self.object.page and self.object.page.revisions.exists(): kwargs["page_revision"] = (
kwargs["page_revision"] = self.object.page.revisions.last().content PageRev.objects.filter(page_id=self.object.page_id)
.order_by("-date")
.first()
)
return kwargs return kwargs
@ -452,23 +448,23 @@ class ClubSellingCSVView(ClubSellingView):
class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView): 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 model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
form_class = ClubEditForm template_name = "club/edit_club.jinja"
template_name = "core/edit.jinja"
current_tab = "edit" current_tab = "edit"
def get_form_class(self):
class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView): if self.object.is_owned_by(self.request.user):
"""Edit the properties of a Club object (for the Sith admins).""" return ClubAdminEditForm
return ClubEditForm
model = Club
pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent", "is_active"]
template_name = "core/edit.jinja"
current_tab = "props"
class ClubCreateView(PermissionRequiredMixin, CreateView): class ClubCreateView(PermissionRequiredMixin, CreateView):
@ -476,8 +472,8 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"] fields = ["name", "parent"]
template_name = "core/edit.jinja" template_name = "core/create.jinja"
permission_required = "club.add_club" 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}) 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): class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""A list of mailing for a given club.""" """A list of mailing for a given club."""
@ -542,26 +529,19 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() 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["user_id"] = self.request.user.id
kwargs["mailings"] = self.mailings kwargs["mailings"] = self.object.mailings.all()
return kwargs 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): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**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["user"] = self.request.user
kwargs["mailings"] = self.mailings kwargs["mailings"] = mailings
kwargs["mailings_moderated"] = ( kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated]
kwargs["mailings"].exclude(is_moderated=False).all() kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated]
)
kwargs["mailings_not_moderated"] = (
kwargs["mailings"].exclude(is_moderated=True).all()
)
kwargs["form_actions"] = { kwargs["form_actions"] = {
"NEW_MALING": self.form_class.ACTION_NEW_MAILING, "NEW_MALING": self.form_class.ACTION_NEW_MAILING,
"NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION, "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: def add_new_mailing(self, cleaned_data) -> ValidationError | None:
"""Create a new mailing list from the form.""" """Create a new mailing list from the form."""
mailing = Mailing( mailing = Mailing(
club=self.get_object(), club=self.object,
email=cleaned_data["mailing_email"], email=cleaned_data["mailing_email"],
moderator=self.request.user, moderator=self.request.user,
is_moderated=False, is_moderated=False,
@ -649,7 +629,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
return resp return resp
def get_success_url(self, **kwargs): 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): class MailingDeleteView(CanEditMixin, DeleteView):

View File

@ -2,7 +2,10 @@ from pydantic import TypeAdapter
from club.models import Club from club.models import Club
from club.schemas import ClubSchema 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"] _js = ["bundled/club/components/ajax-select-index.ts"]

View File

@ -9,7 +9,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema 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.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm from core.auth.api_permissions import HasPerm

View File

@ -8,7 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club 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 com.models import News, NewsDate, Poster
from core.models import User from core.models import User
from core.utils import get_end_of_semester from core.utils import get_end_of_semester

View File

@ -337,7 +337,7 @@ class Screen(models.Model):
def active_posters(self): def active_posters(self):
now = timezone.now() 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) Q(date_end__isnull=True) | Q(date_end__gte=now)
) )

View File

@ -2,7 +2,7 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema from ninja import FilterSchema, ModelSchema
from ninja_extra import service_resolver from ninja_extra import service_resolver
from ninja_extra.controllers import RouteContext from ninja_extra.context import RouteContext
from pydantic import Field from pydantic import Field
from club.schemas import ClubProfileSchema from club.schemas import ClubProfileSchema

View File

@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from com.calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News from com.models import News

View File

@ -16,7 +16,7 @@ from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from com.calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate from com.models import News, NewsDate
from core.markdown import markdown from core.markdown import markdown
from core.models import User from core.models import User

View File

@ -305,7 +305,7 @@ class TestNewsCreation(TestCase):
# we will just test that the ICS is modified. # we will just test that the ICS is modified.
# Checking that the ICS is *well* modified is up to the ICS tests # 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) self.client.post(reverse("com:news_new"), self.valid_payload)
mocked.assert_called() mocked.assert_called()
@ -314,7 +314,7 @@ class TestNewsCreation(TestCase):
self.valid_payload["occurrences"] = 2 self.valid_payload["occurrences"] = 2
last_news = News.objects.order_by("id").last() 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( self.client.post(
reverse("com:news_edit", kwargs={"news_id": last_news.id}), reverse("com:news_edit", kwargs={"news_id": last_news.id}),
self.valid_payload, self.valid_payload,

View File

@ -43,8 +43,8 @@ from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm 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 com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.auth.mixins import ( from core.auth.mixins import (
CanEditPropMixin, CanEditPropMixin,

View File

@ -169,10 +169,9 @@ class CanCreateMixin(View):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionDenied raise PermissionDenied
return res return super().dispatch(request, *arg, **kwargs)
def form_valid(self, form): def form_valid(self, form):
obj = form.instance obj = form.instance

View File

@ -36,21 +36,12 @@ from django.utils import timezone
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import Image from PIL import Image
from accounting.models import (
AccountingType,
BankAccount,
ClubAccount,
Company,
GeneralJournal,
Operation,
SimplifiedAccountingType,
)
from club.models import Club, Membership 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 com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image 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 election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UV from pedagogy.models import UV
@ -120,10 +111,7 @@ class Command(BaseCommand):
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root) sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create( main_club = Club.objects.create(
id=1, id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
name=settings.SITH_MAIN_CLUB["name"],
unix_name=settings.SITH_MAIN_CLUB["unix_name"],
address=settings.SITH_MAIN_CLUB["address"],
) )
main_club.board_group.permissions.add( main_club.board_group.permissions.add(
*Permission.objects.filter( *Permission.objects.filter(
@ -131,16 +119,14 @@ class Command(BaseCommand):
) )
) )
bar_club = Club.objects.create( bar_club = Club.objects.create(
id=2, id=settings.SITH_PDF_CLUB_ID,
name=settings.SITH_BAR_MANAGER["name"], name="PdF",
unix_name=settings.SITH_BAR_MANAGER["unix_name"], address="6 Boulevard Anatole France, 90000 Belfort",
address=settings.SITH_BAR_MANAGER["address"],
) )
Club.objects.create( Club.objects.create(
id=84, id=settings.SITH_LAUNDERETTE_CLUB_ID,
name=settings.SITH_LAUNDERETTE_MANAGER["name"], name="Laverie",
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"], address="6 Boulevard Anatole France, 90000 Belfort",
address=settings.SITH_LAUNDERETTE_MANAGER["address"],
) )
self.reset_index("club") self.reset_index("club")
@ -353,31 +339,17 @@ Welcome to the wiki page!
# Clubs # Clubs
Club.objects.create( Club.objects.create(
name="Bibo'UT", name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
unix_name="bibout",
address="46 de la Boustifaille",
parent=main_club,
) )
guyut = Club.objects.create( guyut = Club.objects.create(
name="Guy'UT", name="Guy'UT", address="42 de la Boustifaille", parent=main_club
unix_name="guyut",
address="42 de la Boustifaille",
parent=main_club,
)
Club.objects.create(
name="Woenzel'UT", unix_name="woenzel", address="Woenzel", parent=guyut
) )
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create( troll = Club.objects.create(
name="Troll Penché", name="Troll Penché", address="Terre Du Milieu", parent=main_club
unix_name="troll",
address="Terre Du Milieu",
parent=main_club,
) )
refound = Club.objects.create( refound = Club.objects.create(
name="Carte AE", name="Carte AE", address="Jamais imprimée", parent=main_club
unix_name="carte_ae",
address="Jamais imprimée",
parent=main_club,
) )
Membership.objects.create(user=skia, club=main_club, role=3) Membership.objects.create(user=skia, club=main_club, role=3)
@ -470,7 +442,6 @@ Welcome to the wiki page!
limit_age=18, limit_age=18,
) )
cons = Product.objects.create( cons = Product.objects.create(
id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup", name="Consigne Eco-cup",
code="CONS", code="CONS",
product_type=verre, product_type=verre,
@ -480,7 +451,6 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
dcons = Product.objects.create( dcons = Product.objects.create(
id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
code="DECO", code="DECO",
product_type=verre, product_type=verre,
@ -509,6 +479,14 @@ Welcome to the wiki page!
club=main_club, club=main_club,
limit_age=18, 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( groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus cotis, cotis2, refill, barb, cble, cors, carolus
) )
@ -521,81 +499,10 @@ Welcome to the wiki page!
eboutic.products.add(barb, cotis, cotis2, refill) eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE") 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: ReturnableProduct.objects.create(
BankAccount.objects.create(name="AE TG", club=main_club) product=cons, returned_product=dcons, max_return=3
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
) )
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 # Add barman to counter
Counter.sellers.through.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
@ -919,6 +826,7 @@ Welcome to the wiki page!
"view_album", "view_album",
"view_peoplepicturerelation", "view_peoplepicturerelation",
"add_peoplepicturerelation", "add_peoplepicturerelation",
"add_page",
] ]
) )
) )

View File

@ -64,12 +64,12 @@ class Command(BaseCommand):
) )
) )
self.make_club( 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(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))), random.sample(old_subscribers, k=min(60, len(old_subscribers))),
) )
self.make_club( 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(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))), random.sample(old_subscribers, k=min(80, len(old_subscribers))),
) )
@ -235,7 +235,7 @@ class Command(BaseCommand):
categories = list( categories = list(
ProductType.objects.filter(name__in=[c.name for c in categories]) 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) other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list( groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])

View File

@ -421,13 +421,9 @@ class User(AbstractUser):
def is_launderette_manager(self): def is_launderette_manager(self):
from club.models import Club from club.models import Club
return ( return Club.objects.get(
Club.objects.filter( id=settings.SITH_LAUNDERETTE_CLUB_ID
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"] ).get_membership_for(self)
)
.first()
.get_membership_for(self)
)
@cached_property @cached_property
def is_banned_alcohol(self) -> bool: def is_banned_alcohol(self) -> bool:
@ -880,11 +876,9 @@ class SithFile(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first() 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 self.is_in_sas = sas in self.get_parent_list() or self == sas
copy_rights = False adding = self._state.adding
if self.id is None:
copy_rights = True
super().save(*args, **kwargs) super().save(*args, **kwargs)
if copy_rights: if adding:
self.copy_rights() self.copy_rights()
if self.is_in_sas: if self.is_in_sas:
for user in User.objects.filter( for user in User.objects.filter(
@ -1366,6 +1360,18 @@ class PageRev(models.Model):
class Meta: class Meta:
ordering = ["date"] 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): def __str__(self):
return str(self.__dict__) return str(self.__dict__)
@ -1379,18 +1385,6 @@ class PageRev(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name}) 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): def can_be_edited_by(self, user):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)

View File

@ -0,0 +1,3 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();

View File

@ -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"; import { client } from "#openapi";
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -14,6 +14,7 @@ export interface PaginatedRequest {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number; page_size?: number;
}; };
url: string;
} }
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>( type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
@ -29,8 +30,8 @@ export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>, endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest, options?: PaginatedRequest,
): Promise<T[]> => { ): Promise<T[]> => {
const maxPerPage = 199; const maxPerPage = 200;
const queryParams = options ?? {}; const queryParams = options ?? ({} as PaginatedRequest);
queryParams.query = queryParams.query ?? {}; queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage; queryParams.query.page_size = maxPerPage;
queryParams.query.page = 1; queryParams.query.page = 1;
@ -53,7 +54,7 @@ export const paginated = async <T>(
return results; return results;
}; };
interface Request { interface Request extends TDataShape {
client?: Client; client?: Client;
} }

View File

@ -55,6 +55,14 @@
width: 80%; width: 80%;
} }
.card-top-left {
position: absolute;
top: 10px;
right: 10px;
padding: 10px;
text-align: center;
}
.card-content { .card-content {
color: black; color: black;
display: flex; display: flex;

View File

@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
color: $text-color; color: $text-color;
font-weight: normal; font-weight: normal;
line-height: 1.3em; line-height: 1.3em;
font-family: "Twemoji Country Flags", sans-serif;
&:hover { &:hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;
@ -250,21 +251,31 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start; justify-content: flex-start;
} }
>a { a, button {
font-size: 100%;
margin: 0;
text-align: right; text-align: right;
color: $text-color; color: $text-color;
&:hover { &:hover {
color: $hovered-text-color; color: $hovered-text-color;
} }
}
&:last-child { form#logout-form {
margin: 0;
display: inline;
}
#logout-form button {
color: $red-text-color; color: $red-text-color;
&:hover { &:hover {
color: $hovered-red-text-color; color: $hovered-red-text-color;
} }
} background: none;
border: none;
cursor: pointer;
padding: 0;
} }
} }
} }

View File

@ -464,7 +464,7 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
$col-gap: 1rem; $col-gap: 1rem;
$row-gap: 0.5rem; $row-gap: $col-gap / 3;
&.gap { &.gap {
column-gap: $col-gap; column-gap: $col-gap;

View File

@ -23,6 +23,7 @@
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script> <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
@ -83,18 +84,18 @@
</ul> </ul>
<div id="content"> <div id="content">
{% block tabs %} {%- block tabs -%}
{% include "core/base/tabs.jinja" %} {% include "core/base/tabs.jinja" %}
{% endblock %} {%- endblock -%}
{% block errors%} {%- block errors -%}
{% if error %} {% if error %}
{{ error }} {{ error }}
{% endif %} {% endif %}
{% endblock %} {%- endblock -%}
{% block content %} {%- block content -%}
{% endblock %} {%- endblock -%}
</div> </div>
</div> </div>

View File

@ -59,7 +59,10 @@
</div> </div>
<div class="links"> <div class="links">
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a> <a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a> <form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %}
<button type="submit">{% trans %}Logout{% endtrans %}</button>
</form>
</div> </div>
</div> </div>
<a <a

View File

@ -10,10 +10,17 @@
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{# if the template context has the `object_name` variable,
then this one will be used,
instead of the result of `str(object)` #}
{% if object and not object_name %}
{% set object_name=object %}
{% endif %}
{% block content %} {% block content %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form> </form>
<form method="GET" action="javascript:history.back();"> <form method="GET" action="javascript:history.back();">

View File

@ -12,16 +12,15 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% macro print_page_name(page) %} {%- macro print_page_name(page) -%}
{% if page %} {%- if page -%}
{{ print_page_name(page.parent) }} > {{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a> <a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{% endif %} {%- endif -%}
{% endmacro %} {%- endmacro -%}
{% block content %} {% block content %}
{{ print_page_name(page) }} {{ print_page_name(page) }}
<div class="tool_bar"> <div class="tool_bar">
<div class="tools"> <div class="tools">
{% if page %} {% if page %}

View File

@ -132,12 +132,7 @@
</div> </div>
</div> </div>
</main> </main>
{% if {% if user == profile or user.memberships.ongoing().exists() %}
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 the user is member of a club, he can view the subscription state #}
<hr> <hr>
{% if profile.is_subscribed %} {% if profile.is_subscribed %}
@ -148,9 +143,7 @@
{% endif %} {% endif %}
{% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %} {% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %}
<div> <div>
{# Shows tokens bought by the user #}
{{ show_tokens(profile) }} {{ show_tokens(profile) }}
{# Shows slots took by the user #}
{{ show_slots(profile) }} {{ show_slots(profile) }}
</div> </div>
{% endif %} {% endif %}
@ -164,9 +157,9 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<br> <br>
{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %} {% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak> <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<div class="collapse-header clickable" @click="collapsed = !collapsed"> <div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text"> <span class="collapse-header-text">
@ -198,9 +191,9 @@
</div> </div>
</div> </div>
<hr> <hr>
{% endif %} {% endif %}
<div> <div>
{% if user.is_root or user.is_board_member %} {% if user.is_root or user.is_board_member %}
<form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post"> <form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">
{% csrf_token %} {% csrf_token %}
@ -236,7 +229,7 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -62,6 +62,11 @@
{% trans %}Product types management{% endtrans %} {% trans %}Product types management{% endtrans %}
</a> </a>
</li> </li>
<li>
<a href="{{ url("counter:returnable_list") }}">
{% trans %}Returnable products management{% endtrans %}
</a>
</li>
<li> <li>
<a href="{{ url('counter:cash_summary_list') }}"> <a href="{{ url('counter:cash_summary_list') }}">
{% trans %}Cash register summaries{% endtrans %} {% trans %}Cash register summaries{% endtrans %}
@ -109,28 +114,8 @@
<h4>{% trans %}Accounting{% endtrans %}</h4> <h4>{% trans %}Accounting{% endtrans %}</h4>
<ul> <ul>
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %} {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li> <li><a href="{{ url("counter:account_refound") }}">{% trans %}Refound Account{% endtrans %}</a></li>
<li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
<li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
{% endif %} {% endif %}
{% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
{%- for b in m.club.bank_accounts.all() %}
<li class="rows">
<strong>{% trans %}Bank account: {% endtrans %}</strong>
<a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
</li>
{%- endfor %}
{% if m.club.club_account.exists() -%}
{% for ca in m.club.club_account.all() %}
<li class="rows">
<strong>{% trans %}Club account: {% endtrans %}</strong>
<a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
</li>
{%- endfor %}
{%- endif -%}
{%- endfor %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -18,7 +18,9 @@ from smtplib import SMTPException
import freezegun import freezegun
import pytest import pytest
from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.core import mail from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
@ -223,17 +225,19 @@ def test_full_markdown_syntax():
class TestPageHandling(TestCase): class TestPageHandling(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.root = User.objects.get(username="root") cls.group = baker.make(
cls.root_group = Group.objects.get(name="Root") Group, permissions=[Permission.objects.get(codename="add_page")]
)
cls.user = baker.make(User, groups=[cls.group])
def setUp(self): def setUp(self):
self.client.force_login(self.root) self.client.force_login(self.user)
def test_create_page_ok(self): def test_create_page_ok(self):
"""Should create a page correctly.""" """Should create a page correctly."""
response = self.client.post( response = self.client.post(
reverse("core:page_new"), reverse("core:page_new"),
{"parent": "", "name": "guy", "owner_group": self.root_group.id}, {"parent": "", "name": "guy", "owner_group": self.group.id},
) )
self.assertRedirects( self.assertRedirects(
response, reverse("core:page", kwargs={"page_name": "guy"}) response, reverse("core:page", kwargs={"page_name": "guy"})
@ -249,32 +253,38 @@ class TestPageHandling(TestCase):
def test_create_child_page_ok(self): def test_create_child_page_ok(self):
"""Should create a page correctly.""" """Should create a page correctly."""
# remove all other pages to make sure there is no side effect parent = baker.prepare(Page)
Page.objects.all().delete() parent.save(force_lock=True)
self.client.post( response = self.client.get(
reverse("core:page_new"), reverse("core:page_new") + f"?page={parent._full_name}/new"
{"parent": "", "name": "guy", "owner_group": str(self.root_group.id)},
) )
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"), reverse("core:page_new"),
{ {
"parent": str(page.id), "parent": str(parent.id),
"name": "bibou", "name": "new",
"owner_group": str(self.root_group.id), "owner_group": str(self.group.id),
}, },
) )
response = self.client.get( new_url = reverse("core:page", kwargs={"page_name": f"{parent._full_name}/new"})
reverse("core:page", kwargs={"page_name": "guy/bibou"}) assertRedirects(response, new_url, fetch_redirect_response=False)
) response = self.client.get(new_url)
assert response.status_code == 200 assert response.status_code == 200
assert '<a href="/page/guy/bibou/">' in str(response.content) assert f'<a href="/page/{parent._full_name}/new/">' in response.content.decode()
def test_access_child_page_ok(self): def test_access_child_page_ok(self):
"""Should display a page correctly.""" """Should display a page correctly."""
parent = Page(name="guy", owner_group=self.root_group) parent = Page(name="guy", owner_group=self.group)
parent.save(force_lock=True) 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) page.save(force_lock=True)
response = self.client.get( response = self.client.get(
reverse("core:page", kwargs={"page_name": "guy/bibou"}) reverse("core:page", kwargs={"page_name": "guy/bibou"})
@ -293,7 +303,8 @@ class TestPageHandling(TestCase):
def test_create_page_markdown_safe(self): def test_create_page_markdown_safe(self):
"""Should format the markdown and escape html correctly.""" """Should format the markdown and escape html correctly."""
self.client.post( 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( self.client.post(
reverse("core:page_edit", kwargs={"page_name": "guy"}), reverse("core:page_edit", kwargs={"page_name": "guy"}),

View File

@ -2,6 +2,7 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib import auth
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@ -219,3 +220,12 @@ def test_user_update_groups(client: Client):
manageable_groups[1], manageable_groups[1],
*hidden_groups[:3], *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

View File

@ -65,6 +65,6 @@ class DetailFormView(FormView, BaseDetailView):
# E402: putting those import at the top of the file would also be difficult # E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402 from .files import * # noqa: F403 E402
from .group import * # noqa: F403 E402 from .group import * # noqa: F403 E402
from .index import * # noqa: F403 E402
from .page import * # noqa: F403 E402 from .page import * # noqa: F403 E402
from .site import * # noqa: F403 E402
from .user import * # noqa: F403 E402 from .user import * # noqa: F403 E402

View File

@ -41,7 +41,7 @@ from core.auth.mixins import (
) )
from core.models import Notification, SithFile, User from core.models import Notification, SithFile, User
from core.views.mixins import AllowFragment from core.views.mixins import AllowFragment
from core.views.widgets.select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile, AutoCompleteSelectSithFile,
AutoCompleteSelectUser, AutoCompleteSelectUser,
@ -403,6 +403,7 @@ class FileModerationView(AllowFragment, ListView):
model = SithFile model = SithFile
template_name = "core/file_moderation.jinja" template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False) queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
ordering = "id"
paginate_by = 100 paginate_by = 100
def dispatch(self, request: HttpRequest, *args, **kwargs): def dispatch(self, request: HttpRequest, *args, **kwargs):

View File

@ -50,7 +50,7 @@ from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, SithFile, User from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectGroup, AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,

View File

@ -30,7 +30,7 @@ from core.auth.mixins import CanEditMixin
from core.models import Group, User from core.models import Group, User
from core.views import DetailFormView from core.views import DetailFormView
from core.views.forms import PermissionGroupsForm from core.views.forms import PermissionGroupsForm
from core.views.widgets.select import AutoCompleteSelectMultipleUser from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
# Forms # Forms

View File

@ -1,3 +1,5 @@
from typing import ClassVar
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.views import View from django.views import View
@ -6,20 +8,24 @@ from django.views import View
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """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): def get_tabs_title(self):
if hasattr(self, "tabs_title"): if not self.tabs_title:
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required") raise ImproperlyConfigured("tabs_title is required")
return self.tabs_title
def get_current_tab(self): def get_current_tab(self):
if hasattr(self, "current_tab"): if not self.current_tab:
return self.current_tab
raise ImproperlyConfigured("current_tab is required") raise ImproperlyConfigured("current_tab is required")
return self.current_tab
def get_list_of_tabs(self): def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"): if not self.list_of_tabs:
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required") raise ImproperlyConfigured("list_of_tabs is required")
return self.list_of_tabs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from django.forms.models import modelform_factory 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 django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import ( from core.auth.mixins import (
CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
@ -115,20 +115,22 @@ class PageRevView(CanViewMixin, DetailView):
return context return context
class PageCreateView(CanCreateMixin, CreateView): class PageCreateView(PermissionRequiredMixin, CreateView):
model = Page model = Page
form_class = PageForm form_class = PageForm
template_name = "core/page_prop.jinja" template_name = "core/page_prop.jinja"
permission_required = "core.add_page"
def get_initial(self): def get_initial(self):
init = {} init = super().get_initial()
if "page" in self.request.GET: if "page" not in self.request.GET:
page_name = self.request.GET["page"] return init
parent_name = "/".join(page_name.split("/")[:-1]) page_name = self.request.GET["page"].rsplit("/", maxsplit=1)
parent = Page.get_page_by_full_name(parent_name) if len(page_name) == 2:
parent = Page.get_page_by_full_name(page_name[0])
if parent is not None: if parent is not None:
init["parent"] = parent.id init["parent"] = parent.id
init["name"] = page_name.split("/")[-1] init["name"] = page_name[-1]
return init return init
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -28,7 +28,6 @@ from datetime import date, timedelta
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from django.conf import settings
from django.contrib.auth import login, views from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -65,7 +64,7 @@ from core.views.forms import (
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin 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 eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@ -205,14 +204,6 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Pictures"), "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: if self.request.user == user:
tab_list.append( tab_list.append(
{"url": reverse("core:user_tools"), "slug": "tools", "name": _("Tools")} {"url": reverse("core:user_tools"), "slug": "tools", "name": _("Tools")}
@ -251,17 +242,7 @@ class UserTabsMixin(TabedViewMixin):
if ( if (
hasattr(user, "customer") hasattr(user, "customer")
and user.customer and user.customer
and ( and (user == self.request.user or user.has_perm("counter.view_customer"))
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
)
): ):
tab_list.append( tab_list.append(
{ {
@ -370,12 +351,7 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
raise Http404 raise Http404
if not ( if not (
profile == request.user profile == request.user or request.user.has_perm("counter.view_customer")
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
): ):
raise PermissionDenied raise PermissionDenied
@ -385,8 +361,6 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
from django.db.models import Sum from django.db.models import Sum
from counter.models import Counter
foyer = Counter.objects.filter(name="Foyer").first() foyer = Counter.objects.filter(name="Foyer").first()
mde = Counter.objects.filter(name="MDE").first() mde = Counter.objects.filter(name="MDE").first()
gommette = Counter.objects.filter(name="La Gommette").first() gommette = Counter.objects.filter(name="La Gommette").first()
@ -599,14 +573,9 @@ class UserAccountBase(UserTabsMixin, DetailView):
current_tab = "account" current_tab = "account"
queryset = User.objects.select_related("customer") queryset = User.objects.select_related("customer")
def dispatch(self, request, *arg, **kwargs): # Manually validates the rights def dispatch(self, request, *arg, **kwargs):
if ( if kwargs.get("user_id") == request.user.id or request.user.has_perm(
kwargs.get("user_id") == request.user.id "counter.view_customer"
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
): ):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied raise PermissionDenied

View File

@ -26,6 +26,7 @@ from counter.models import (
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
) )
@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin):
search_fields = ("name", "code") 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) @admin.register(Customer)
class CustomerAdmin(SearchModelAdmin): class CustomerAdmin(SearchModelAdmin):
list_display = ("user", "account_id", "amount") list_display = ("user", "account_id", "amount")

30
counter/fields.py Normal file
View File

@ -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

View File

@ -2,9 +2,10 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget 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.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser, AutoCompleteSelectMultipleUser,
@ -17,9 +18,10 @@ from counter.models import (
Eticket, Eticket,
Product, Product,
Refilling, Refilling,
ReturnableProduct,
StudentCard, StudentCard,
) )
from counter.widgets.select import ( from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter, AutoCompleteSelectMultipleCounter,
AutoCompleteSelectMultipleProduct, AutoCompleteSelectMultipleProduct,
AutoCompleteSelectProduct, AutoCompleteSelectProduct,
@ -213,6 +215,25 @@ class ProductEditForm(forms.ModelForm):
return ret 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): class CashSummaryFormBase(forms.Form):
begin_date = forms.DateTimeField( begin_date = forms.DateTimeField(
label=_("Begin date"), widget=SelectDateTime, required=False label=_("Begin date"), widget=SelectDateTime, required=False
@ -230,3 +251,13 @@ class EticketForm(forms.ModelForm):
"product": AutoCompleteSelectProduct, "product": AutoCompleteSelectProduct,
"event_date": SelectDate, "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(),
)

View File

@ -11,7 +11,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import User, UserQuerySet 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): class Command(BaseCommand):
@ -106,6 +106,7 @@ class Command(BaseCommand):
raise ValueError("One or more accounts were not engaged in a dump process") raise ValueError("One or more accounts were not engaged in a dump process")
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID) counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
seller = User.objects.get(pk=settings.SITH_ROOT_USER_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( sales = Selling.objects.bulk_create(
[ [
Selling( Selling(
@ -113,7 +114,7 @@ class Command(BaseCommand):
club=counter.club, club=counter.club,
counter=counter, counter=counter,
seller=seller, seller=seller,
product=None, product=product,
customer=account, customer=account,
quantity=1, quantity=1,
unit_price=account.amount, unit_price=account.amount,
@ -126,7 +127,7 @@ class Command(BaseCommand):
sales.sort(key=attrgetter("customer_id")) sales.sort(key=attrgetter("customer_id"))
# dumps and sales are linked to the same customers # 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): for dump, sale in zip(pending_dumps, sales, strict=False):
dump.dump_operation = sale dump.dump_operation = sale
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"]) AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])

View File

@ -4,7 +4,7 @@ import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import accounting.models import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -78,7 +78,7 @@ class Migration(migrations.Migration):
), ),
( (
"amount", "amount",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount" decimal_places=2, max_digits=12, verbose_name="amount"
), ),
), ),
@ -145,19 +145,19 @@ class Migration(migrations.Migration):
), ),
( (
"purchase_price", "purchase_price",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="purchase price" decimal_places=2, max_digits=12, verbose_name="purchase price"
), ),
), ),
( (
"selling_price", "selling_price",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="selling price" decimal_places=2, max_digits=12, verbose_name="selling price"
), ),
), ),
( (
"special_selling_price", "special_selling_price",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, decimal_places=2,
max_digits=12, max_digits=12,
verbose_name="special selling price", verbose_name="special selling price",
@ -240,7 +240,7 @@ class Migration(migrations.Migration):
), ),
( (
"amount", "amount",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount" 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")), ("label", models.CharField(max_length=64, verbose_name="label")),
( (
"unit_price", "unit_price",
accounting.models.CurrencyField( counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="unit price" decimal_places=2, max_digits=12, verbose_name="unit price"
), ),
), ),

View File

@ -4,7 +4,7 @@ import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import accounting.models import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -67,7 +67,7 @@ class Migration(migrations.Migration):
), ),
( (
"value", "value",
accounting.models.CurrencyField( counter.fields.CurrencyField(
max_digits=12, verbose_name="value", decimal_places=2 max_digits=12, verbose_name="value", decimal_places=2
), ),
), ),

View File

@ -2,7 +2,7 @@
from django.db import migrations from django.db import migrations
import accounting.models import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -14,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="customer", model_name="customer",
name="amount", name="amount",
field=accounting.models.CurrencyField( field=counter.fields.CurrencyField(
decimal_places=2, default=0, max_digits=12, verbose_name="amount" decimal_places=2, default=0, max_digits=12, verbose_name="amount"
), ),
), ),

View File

@ -2,7 +2,7 @@
from django.db import migrations, models from django.db import migrations, models
import accounting.models import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="product", model_name="product",
name="purchase_price", name="purchase_price",
field=accounting.models.CurrencyField( field=counter.fields.CurrencyField(
decimal_places=2, decimal_places=2,
help_text="Initial cost of purchasing the product", help_text="Initial cost of purchasing the product",
max_digits=12, max_digits=12,
@ -28,7 +28,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="product", model_name="product",
name="special_selling_price", name="special_selling_price",
field=accounting.models.CurrencyField( field=counter.fields.CurrencyField(
decimal_places=2, decimal_places=2,
help_text="Price for barmen during their permanence", help_text="Price for barmen during their permanence",
max_digits=12, max_digits=12,

View File

@ -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"),
]

View File

@ -21,7 +21,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Self from typing import Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@ -38,13 +38,12 @@ from django_countries.fields import CountryField
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField
from club.models import Club from club.models import Club
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Notification, User from core.models import Group, Notification, User
from core.utils import get_start_of_semester from core.utils import get_start_of_semester
from counter.apps import PAYMENT_METHOD from counter.apps import PAYMENT_METHOD
from sith.settings import SITH_MAIN_CLUB from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
@ -94,7 +93,6 @@ class Customer(models.Model):
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
account_id = models.CharField(_("account id"), max_length=10, unique=True) account_id = models.CharField(_("account id"), max_length=10, unique=True)
amount = CurrencyField(_("amount"), default=0) amount = CurrencyField(_("amount"), default=0)
recorded_products = models.IntegerField(_("recorded product"), default=0)
objects = CustomerQuerySet.as_manager() objects = CustomerQuerySet.as_manager()
@ -106,24 +104,50 @@ class Customer(models.Model):
def __str__(self): def __str__(self):
return "%s - %s" % (self.user.username, self.account_id) 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 """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 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. 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")) raise ValidationError(_("Not enough money"))
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:user_account", kwargs={"user_id": self.user.pk}) return reverse("core:user_account", kwargs={"user_id": self.user.pk})
@property def update_returnable_balance(self):
def can_record(self): """Update all returnable balances of this user to their real amount."""
return self.recorded_products > -settings.SITH_ECOCUP_LIMIT
def can_record_more(self, number): def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]):
return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT 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 @property
def can_buy(self) -> bool: def can_buy(self) -> bool:
@ -379,14 +403,6 @@ class Product(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("counter:product_list") 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): def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
@ -514,11 +530,6 @@ class Counter(models.Model):
def __str__(self): def __str__(self):
return self.name 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): def __getattribute__(self, name: str):
if name == "edit_groups": if name == "edit_groups":
return Group.objects.filter( return Group.objects.filter(
@ -526,6 +537,11 @@ class Counter(models.Model):
).all() ).all()
return object.__getattribute__(self, name) 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: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
return False return False
@ -569,7 +585,7 @@ class Counter(models.Model):
if self.type != "BAR": if self.type != "BAR":
return False return False
# at least one of the barmen is in the AE board # 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) return any(ae.get_membership_for(barman) for barman in self.barmen_list)
def get_top_barmen(self) -> QuerySet: def get_top_barmen(self) -> QuerySet:
@ -860,7 +876,7 @@ class Selling(models.Model):
self.full_clean() self.full_clean()
if not self.is_validated: if not self.is_validated:
self.customer.amount -= self.quantity * self.unit_price 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 self.is_validated = True
user = self.customer.user user = self.customer.user
if user.was_subscribed: if user.was_subscribed:
@ -945,6 +961,7 @@ class Selling(models.Model):
self.customer.amount += self.quantity * self.unit_price self.customer.amount += self.quantity * self.unit_price
self.customer.save() self.customer.save()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
self.customer.update_returnable_balance()
def send_mail_customer(self): def send_mail_customer(self):
event = self.product.eticket.event_title or _("Unknown event") event = self.product.eticket.event_title or _("Unknown event")
@ -1045,14 +1062,6 @@ class CashRegisterSummary(models.Model):
def __str__(self): def __str__(self):
return "At %s by %s - Total: %s" % (self.counter, self.user, self.get_total()) 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): def __getattribute__(self, name):
if name[:5] == "check": if name[:5] == "check":
checks = self.items.filter(is_check=True).order_by("value").all() checks = self.items.filter(is_check=True).order_by("value").all()
@ -1089,6 +1098,14 @@ class CashRegisterSummary(models.Model):
else: else:
return object.__getattribute__(self, name) 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): def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
@ -1211,3 +1228,134 @@ class StudentCard(models.Model):
if isinstance(obj, User): if isinstance(obj, User):
return StudentCard.can_create(self.customer, obj) return StudentCard.can_create(self.customer, obj)
return False 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}"
)

View File

@ -98,3 +98,5 @@ class ProductFilterSchema(FilterSchema):
is_archived: bool | None = Field(None, q="archived") is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in") buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__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")

View File

@ -1,6 +1,6 @@
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; 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", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({ Alpine.data("counter", (config: CounterConfig) => ({

View File

@ -60,6 +60,8 @@ document.addEventListener("alpine:init", () => {
productStatus: "" as "active" | "archived" | "both", productStatus: "" as "active" | "archived" | "both",
search: "", search: "",
productTypes: [] as string[], productTypes: [] as string[],
clubs: [] as string[],
counters: [] as string[],
pageSize: defaultPageSize, pageSize: defaultPageSize,
page: defaultPage, page: defaultPage,
@ -67,13 +69,27 @@ document.addEventListener("alpine:init", () => {
const url = getCurrentUrlParams(); const url = getCurrentUrlParams();
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active"; this.productStatus = url.get("productStatus") ?? "active";
const widget = this.$refs.productTypesInput.widget as TomSelect; const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
widget.on("change", (items: string[]) => { productTypesWidget.on("change", (items: string[]) => {
this.productTypes = [...items]; 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(); await this.load();
const searchParams = ["search", "productStatus", "productTypes"]; const searchParams = [
"search",
"productStatus",
"productTypes",
"clubs",
"counters",
];
for (const param of searchParams) { for (const param of searchParams) {
this.$watch(param, () => { this.$watch(param, () => {
this.page = defaultPage; this.page = defaultPage;
@ -92,7 +108,7 @@ document.addEventListener("alpine:init", () => {
* Build the object containing the query parameters corresponding * Build the object containing the query parameters corresponding
* to the current filters * to the current filters
*/ */
getQueryParams(): ProductSearchProductsDetailedData { getQueryParams(): Omit<ProductSearchProductsDetailedData, "url"> {
const search = this.search.length > 0 ? this.search : null; const search = this.search.length > 0 ? this.search : null;
// If active or archived products must be filtered, put the filter in the request // If active or archived products must be filtered, put the filter in the request
// Else, don't include the filter // Else, don't include the filter
@ -109,6 +125,8 @@ document.addEventListener("alpine:init", () => {
is_archived: isArchived, is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes], product_type: [...this.productTypes],
club: [...this.clubs],
counter: [...this.counters],
}, },
}; };
}, },
@ -121,14 +139,17 @@ document.addEventListener("alpine:init", () => {
const options = this.getQueryParams(); const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options); const resp = await productSearchProductsDetailed(options);
this.nbPages = Math.ceil(resp.data.count / defaultPageSize); this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => { this.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => {
const key = curr.product_type?.name ?? gettext("Uncategorized"); const key = curr.product_type?.name ?? gettext("Uncategorized");
if (!(key in acc)) { if (!(key in acc)) {
acc[key] = []; acc[key] = [];
} }
acc[key].push(curr); acc[key].push(curr);
return acc; return acc;
}, {}); },
{},
);
this.loading = false; this.loading = false;
}, },

View File

@ -7,6 +7,7 @@
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script> <script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/club/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script> <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% endblock %} {% endblock %}
@ -22,7 +23,6 @@
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4> <h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
<form id="search-form" class="margin-bottom"> <form id="search-form" class="margin-bottom">
<div class="row gap-4x"> <div class="row gap-4x">
<fieldset> <fieldset>
<label for="search-input">{% trans %}Product name{% endtrans %}</label> <label for="search-input">{% trans %}Product name{% endtrans %}</label>
<input <input
@ -48,16 +48,34 @@
</div> </div>
</fieldset> </fieldset>
</div> </div>
<fieldset> <div class="row gap-4x">
<fieldset class="grow">
<label for="type-search-input">{% trans %}Product type{% endtrans %}</label> <label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
<product-type-ajax-select <product-type-ajax-select
id="type-search-input" id="type-search-input"
name="product-type" name="product-type"
x-ref="productTypesInput" x-ref="productTypesInput"
multiple multiple
> ></product-type-ajax-select>
</product-type-ajax-select>
</fieldset> </fieldset>
<fieldset class="grow">
<label for="club-search-input">{% trans %}Clubs{% endtrans %}</label>
<club-ajax-select
id="club-search-input"
name="club"
x-ref="clubsInput"
multiple></club-ajax-select>
</fieldset>
<fieldset class="grow">
<label for="counter-search-input">{% trans %}Counters{% endtrans %}</label>
<counter-ajax-select
id="counter-search-input"
name="counter"
x-ref="countersInput"
multiple
></counter-ajax-select>
</fieldset>
</div>
</form> </form>
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>

View File

@ -0,0 +1,67 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Returnable products{% endtrans %}
{% endblock %}
{% block additional_js %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<h3 class="margin-bottom">{% trans %}Returnable products{% endtrans %}</h3>
{% if user.has_perm("counter.add_returnableproduct") %}
<a href="{{ url('counter:create_returnable') }}" class="btn btn-blue margin-bottom">
{% trans %}New returnable product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<div class="product-group">
{% for returnable in object_list %}
{% if user.has_perm("counter.change_returnableproduct") %}
<a
class="card card-row shadow clickable"
href="{{ url("counter:edit_returnable", returnable_id=returnable.id) }}"
>
{% else %}
<div class="card card-row shadow">
{% endif %}
{% if returnable.product.icon %}
<img
class="card-image"
src="{{ returnable.product.icon.url }}"
alt="{{ returnable.product.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<strong class="card-title">{{ returnable.product }}</strong>
<p>{% trans %}Returned product{% endtrans %} : {{ returnable.returned_product }}</p>
</div>
{% if user.has_perm("counter.delete_returnableproduct") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url("counter:delete_returnable", returnable_id=returnable.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
{% if user.has_perm("counter.change_returnableproduct") %}
</a>
{% else %}
</div>
{% endif %}
{% endfor %}
</div>
{% endblock content %}

View File

@ -28,17 +28,19 @@ from django.utils import timezone
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from freezegun import freeze_time from freezegun import freeze_time
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, 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 ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Product, Product,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
) )
@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase):
self, self,
user: User | Customer, user: User | Customer,
counter: Counter, counter: Counter,
amount: int, amount: int | float,
client: Client | None = None, client: Client | None = None,
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase):
special_selling_price="-1.5", special_selling_price="-1.5",
) )
cls.beer = product_recipe.make( 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( cls.beer_tap = product_recipe.make(
limit_age=18, limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
tray=True,
selling_price="1.5",
special_selling_price="1",
) )
cls.snack = product_recipe.make( 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( 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.counter.products.add(
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
) )
cls.other_counter.products.add(cls.snack) cls.other_counter.products.add(cls.snack)
cls.club_counter.products.add(cls.stamps) cls.club_counter.products.add(cls.stamps)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase):
def test_click_eboutic_failure(self): def test_click_eboutic_failure(self):
eboutic = baker.make(Counter, type="EBOUTIC") eboutic = baker.make(Counter, type="EBOUTIC")
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic
self.customer,
[BasketItem(self.stamps.id, 5)],
counter=eboutic,
).status_code
== 404
) )
assert res.status_code == 404
def test_click_office_success(self): def test_click_office_success(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
self.submit_basket(
self.customer,
[BasketItem(self.stamps.id, 5)],
counter=self.club_counter,
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal("2.5") assert self.updated_amount(self.customer) == Decimal("2.5")
# Test no special price on office counter # Test no special price on office counter
self.refill_user(self.club_admin, 10) self.refill_user(self.club_admin, 10)
res = self.submit_basket(
assert ( self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
self.submit_basket(
self.club_admin,
[BasketItem(self.stamps.id, 1)],
counter=self.club_counter,
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.club_admin) == Decimal("8.5") assert self.updated_amount(self.club_admin) == Decimal("8.5")
def test_click_bar_success(self): def test_click_bar_success(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 2),
BasketItem(self.snack.id, 1),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal("5.5") assert self.updated_amount(self.customer) == Decimal("5.5")
@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
# Not applying tray price # Not applying tray price
assert ( res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
self.submit_basket( assert res.status_code == 302
self.customer,
[
BasketItem(self.beer_tap.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal("17") assert self.updated_amount(self.customer) == Decimal("17")
# Applying tray price # Applying tray price
assert ( res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
self.submit_basket( assert res.status_code == 302
self.customer,
[
BasketItem(self.beer_tap.id, 7),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal("8") assert self.updated_amount(self.customer) == Decimal("8")
def test_click_alcool_unauthorized(self): def test_click_alcool_unauthorized(self):
@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase):
self.refill_user(user, 10) self.refill_user(user, 10)
# Buy product without age limit # Buy product without age limit
assert ( res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
self.submit_basket( assert res.status_code == 302
user,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(user) == Decimal("7") assert self.updated_amount(user) == Decimal("7")
# Buy product without age limit # Buy product without age limit
assert ( res = self.submit_basket(user, [BasketItem(self.beer.id, 2)])
self.submit_basket( assert res.status_code == 200
user,
[
BasketItem(self.beer.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(user) == Decimal("7") assert self.updated_amount(user) == Decimal("7")
@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase):
self.customer_old_can_not_buy, self.customer_old_can_not_buy,
]: ]:
self.refill_user(user, 10) self.refill_user(user, 10)
resp = self.submit_basket( resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
user,
[
BasketItem(self.snack.id, 2),
],
)
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.url == resolve_url(self.counter) assert resp.url == resolve_url(self.counter)
@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase):
def test_click_user_without_customer(self): def test_click_user_without_customer(self):
self.login_in_bar() self.login_in_bar()
assert ( res = self.submit_basket(
self.submit_basket( self.customer_can_not_buy, [BasketItem(self.snack.id, 2)]
self.customer_can_not_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 404
) )
assert res.status_code == 404
def test_click_allowed_old_subscriber(self): def test_click_allowed_old_subscriber(self):
self.login_in_bar() self.login_in_bar()
self.refill_user(self.customer_old_can_buy, 10) self.refill_user(self.customer_old_can_buy, 10)
assert ( res = self.submit_basket(
self.submit_basket( self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
self.customer_old_can_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7") assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
def test_click_wrong_counter(self): def test_click_wrong_counter(self):
self.login_in_bar() self.login_in_bar()
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.other_counter,
).status_code
== 302 # Redirect to counter main
) )
assertRedirects(res, self.other_counter.get_absolute_url())
# We want to test sending requests from another counter while # We want to test sending requests from another counter while
# we are currently registered to another counter # we are currently registered to another counter
@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase):
# that using a client not logged to a counter # that using a client not logged to a counter
# where another client is logged still isn't authorized. # where another client is logged still isn't authorized.
client = Client() client = Client()
assert ( res = self.submit_basket(
self.submit_basket(
self.customer, self.customer,
[ [BasketItem(self.snack.id, 2)],
BasketItem(self.snack.id, 2),
],
counter=self.counter, counter=self.counter,
client=client, client=client,
).status_code
== 302 # Redirect to counter main
) )
assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self): def test_click_not_connected(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
self.submit_basket( assertRedirects(res, self.counter.get_absolute_url())
self.customer,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302 # Redirect to counter main
)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.club_counter,
).status_code
== 403
) )
assert res.status_code == 403
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar() self.login_in_bar()
assert ( res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
self.submit_basket( assert res.status_code == 200
self.customer,
[
BasketItem(self.stamps.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_invalid(self): def test_click_product_invalid(self):
@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar() self.login_in_bar()
for item in [ for item in [
BasketItem("-1", 2), BasketItem(-1, 2),
BasketItem(self.beer.id, -1), BasketItem(self.beer.id, -1),
BasketItem(None, 1), BasketItem(None, 1),
BasketItem(self.beer.id, None), BasketItem(self.beer.id, None),
BasketItem(None, None), BasketItem(None, None),
]: ]:
assert ( assert self.submit_basket(self.customer, [item]).status_code == 200
self.submit_basket(
self.customer,
[item],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_enough_money(self): def test_click_not_enough_money(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar() self.login_in_bar()
res = self.submit_basket(
assert (
self.submit_basket(
self.customer, self.customer,
[ [BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)],
BasketItem(self.beer_tap.id, 5),
BasketItem(self.beer.id, 10),
],
).status_code
== 200
) )
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase):
def test_selling_ordering(self): def test_selling_ordering(self):
# Cheaper items should be processed with a higher priority # Cheaper items should be processed with a higher priority
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 1),
BasketItem(self.gift.id, 1),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_recordings(self): def test_recordings(self):
self.refill_user(self.customer, self.cons.selling_price * 3) self.refill_user(self.customer, self.cons.selling_price * 3)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
self.submit_basket( assert res.status_code == 302
self.customer,
[BasketItem(self.cons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == 0 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 ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
self.submit_basket( assert res.status_code == 302
self.customer,
[BasketItem(self.dcons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3 assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
self.customer,
[BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
).status_code
== 302
) )
# 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 * ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
-3 - settings.SITH_ECOCUP_LIMIT assert res.status_code == 200
) assert self.updated_amount(self.customer) == expected_amount
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)]
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
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == expected_amount
def test_recordings_when_negative(self): def test_recordings_when_negative(self):
self.refill_user( sale_recipe.make(
self.customer, customer=self.customer.customer,
self.cons.selling_price * 3 + Decimal(self.beer.selling_price), product=self.dcons,
unit_price=self.dcons.selling_price,
quantity=10,
) )
self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10 self.customer.customer.update_returnable_balance()
self.customer.customer.save()
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
self.submit_basket( assert res.status_code == 200
self.customer, assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
[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.cons.id, 3)])
assert res.status_code == 302
assert ( assert (
self.submit_basket( self.updated_amount(self.customer)
self.customer, == self.dcons.selling_price * -10 - self.cons.selling_price * 3
[BasketItem(self.beer.id, 1)], )
).status_code
== 302 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): class TestCounterStats(TestCase):
@ -783,7 +634,7 @@ class TestCounterStats(TestCase):
s = Selling( s = Selling(
label=barbar.name, label=barbar.name,
product=barbar, product=barbar,
club=Club.objects.get(name=settings.SITH_MAIN_CLUB["name"]), club=baker.make(Club),
counter=cls.counter, counter=cls.counter,
unit_price=2, unit_price=2,
seller=cls.skia, seller=cls.skia,

View File

@ -14,12 +14,13 @@ from model_bakery import baker
from club.models import Membership from club.models import Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import 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 ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
Customer, Customer,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
StudentCard, StudentCard,
) )
@ -482,3 +483,31 @@ def test_update_balance():
for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False): for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False):
customer.refresh_from_db() customer.refresh_from_db()
assert customer.amount == amount 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}

View File

@ -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 '<form action="" method="post">' 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 '<form action="" method="post">' in str(response.content)
assert self.skia.customer.amount == 0

View File

@ -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},
]

View File

@ -30,6 +30,11 @@ from counter.views.admin import (
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
RefillingDeleteView, RefillingDeleteView,
RefoundAccountView,
ReturnableProductCreateView,
ReturnableProductDeleteView,
ReturnableProductListView,
ReturnableProductUpdateView,
SellingDeleteView, SellingDeleteView,
) )
from counter.views.auth import counter_login, counter_logout from counter.views.auth import counter_login, counter_logout
@ -51,10 +56,7 @@ from counter.views.home import (
CounterMain, CounterMain,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import ( from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
StudentCardDeleteView,
StudentCardFormView,
)
urlpatterns = [ urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"), path("<int:counter_id>/", CounterMain.as_view(), name="details"),
@ -129,6 +131,24 @@ urlpatterns = [
ProductTypeEditView.as_view(), ProductTypeEditView.as_view(),
name="product_type_edit", 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/<int:returnable_id>/",
ReturnableProductUpdateView.as_view(),
name="edit_returnable",
),
path(
"admin/returnable/delete/<int:returnable_id>/",
ReturnableProductDeleteView.as_view(),
name="delete_returnable",
),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
path( path(
@ -151,4 +171,5 @@ urlpatterns = [
CounterRefillingListView.as_view(), CounterRefillingListView.as_view(),
name="refilling_list", name="refilling_list",
), ),
path("admin/refound/", RefoundAccountView.as_view(), name="account_refound"),
] ]

View File

@ -15,19 +15,34 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView 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.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from counter.forms import CounterEditForm, ProductEditForm from counter.forms import (
from counter.models import Counter, Product, ProductType, Refilling, Selling CloseCustomerAccountForm,
CounterEditForm,
ProductEditForm,
ReturnableProductForm,
)
from counter.models import (
Counter,
Product,
ProductType,
Refilling,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@ -146,6 +161,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" 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): class RefillingDeleteView(DeleteView):
"""Delete a refilling (for the admins).""" """Delete a refilling (for the admins)."""
@ -253,3 +331,42 @@ class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListVie
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.counter kwargs["counter"] = self.counter
return kwargs 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()

Some files were not shown because too many files have changed in this diff Show More