mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-22 08:50:17 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 186498d904 | |||
| d4a2b7ec33 | |||
| d35a2945fc | |||
| dbe29669e6 | |||
| 26585aa521 | |||
| 99600341b0 | |||
| e667058fe6 | |||
| ffa4f82c7d | |||
| 307f1fb1fe | |||
| b6522971b2 | |||
| 66f826625e | |||
| a74f0dde39 | |||
| c7160727ce | |||
| 31f36506a4 | |||
| 3d8a832aca | |||
| 07b08180d5 | |||
| bfd147fd3c | |||
| b784fd7ac8 | |||
| 899fc90821 | |||
| 40a67f8c08 | |||
| 51c8e1e3dc | |||
| d3094a850f | |||
| 9fdd5565a1 | |||
| f0ac35370e | |||
| 54d261142d | |||
| 8876c64f54 | |||
| a40c43204a | |||
| 44e8ab4fb9 | |||
| c9caa3f324 | |||
| aaaaeb204f | |||
| fa190f6909 | |||
| 898ce48cc8 | |||
| 74d8a374b9 | |||
| 11d9e912e1 | |||
| 5cf54729ae | |||
| 387e5fe0a3 | |||
| c371a5cf7a | |||
| 07eae232d0 | |||
| e4fa9c3c4a | |||
|
5551fdc953
|
|||
|
2d86ba67f4
|
|||
| deb83ec08f | |||
| 94cda48508 | |||
| 7bd2f1da96 | |||
| e2d2eb7470 | |||
| a18f316088 | |||
| fee1bbc5a5 | |||
|
cb1a330983
|
|||
| 1cfeeefa56 | |||
| 0308d1c887 | |||
| 9abd16c8f6 | |||
|
0405ef424d
|
|||
| b79b7cbcf5 | |||
| 2c259de22c | |||
| 38f2b6aa7b | |||
| 37345f1bc4 | |||
| 151d17aca1 | |||
| d123e5e35b | |||
| 00f7afb937 | |||
| 2dbf4cff05 | |||
| f88c061b02 | |||
| 4bd248f827 | |||
| 2aa6eed2fc | |||
| 7fec05820c | |||
| 381f1ac829 | |||
| 22e6c09c36 | |||
| 399a3813f0 | |||
| 441a016025 | |||
| 060dde78e7 | |||
| f19b3056ef | |||
| 200f71a762 | |||
| facfa1be89 | |||
| 4b9a953a20 | |||
| 5c17337595 | |||
| da621f6d7e | |||
| 6b2b4d347f | |||
| 26483825a1 | |||
| 9ef592849c | |||
| c329fb1d0e | |||
| ef611ce1a7 | |||
| fb23d24db7 | |||
| 16ca6c3ead | |||
| f8f85c2778 | |||
| cf40e941d4 | |||
| adeadeaf70 | |||
| 40fce1609d | |||
| 0f02d55318 | |||
| 965309d76c | |||
| 26c6ab4a9f | |||
| 2613ede59d | |||
| 6634bd987d | |||
| 1c6d7f435a |
@@ -12,7 +12,7 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install apt packages
|
- name: Install apt packages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
@@ -23,26 +23,29 @@ runs:
|
|||||||
with:
|
with:
|
||||||
redis-version: "7.x"
|
redis-version: "7.x"
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
version: "0.5.14"
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
- name: Restore cached virtualenv
|
- name: Install uv
|
||||||
uses: actions/cache/restore@v4
|
uses: astral-sh/setup-uv@v8.1.0
|
||||||
|
with:
|
||||||
|
version: "0.11.8"
|
||||||
|
enable-cache: false
|
||||||
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
|
- name: Restore cached virtualenv
|
||||||
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
|
||||||
path: .venv
|
path: .venv
|
||||||
|
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||||
|
uv-${{ runner.os }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync
|
run: uv sync --locked
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install Xapian
|
||||||
@@ -50,15 +53,6 @@ runs:
|
|||||||
run: uv run ./manage.py install_xapian
|
run: uv run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# compiling xapian accounts for almost the entirety of the virtualenv setup,
|
|
||||||
# so we save the virtual environment only on workflows where it has been installed
|
|
||||||
- name: Save cached virtualenv
|
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
uses: actions/cache/save@v4
|
|
||||||
with:
|
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
|
||||||
path: .venv
|
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
run: uv run ./manage.py compilemessages
|
run: uv run ./manage.py compilemessages
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ jobs:
|
|||||||
name: Launch pre-commits checks (ruff)
|
name: Launch pre-commits checks (ruff)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@v3.0.1
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
pytest-mark: [not slow]
|
pytest-mark: [not slow]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
with:
|
with:
|
||||||
full: true
|
full: true
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
uv run coverage report
|
uv run coverage report
|
||||||
uv run coverage html
|
uv run coverage html
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: coverage-report-${{ matrix.pytest-mark }}
|
name: coverage-report-${{ matrix.pytest-mark }}
|
||||||
path: coverage_report
|
path: coverage_report
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.1.0
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -29,8 +29,6 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
script_stop: true
|
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
key: mkdocs-material-${{ env.cache_id }}
|
||||||
path: .cache
|
path: .cache
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.1.0
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -28,8 +28,6 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
script_stop: true
|
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
@@ -17,15 +17,6 @@ class ApiClientAdmin(admin.ModelAdmin):
|
|||||||
"owner__nick_name",
|
"owner__nick_name",
|
||||||
)
|
)
|
||||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||||
readonly_fields = ("hmac_key",)
|
|
||||||
actions = ("reset_hmac_key",)
|
|
||||||
|
|
||||||
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
|
|
||||||
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
|
|
||||||
objs = list(queryset)
|
|
||||||
for obj in objs:
|
|
||||||
obj.reset_hmac(commit=False)
|
|
||||||
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ApiKey)
|
@admin.register(ApiKey)
|
||||||
|
|||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
from ninja_extra import ControllerBase, api_controller, route
|
|
||||||
|
|
||||||
from api.auth import ApiKeyAuth
|
|
||||||
from api.schemas import ApiClientSchema
|
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/client")
|
|
||||||
class ApiClientController(ControllerBase):
|
|
||||||
@route.get(
|
|
||||||
"/me",
|
|
||||||
auth=[ApiKeyAuth()],
|
|
||||||
response=ApiClientSchema,
|
|
||||||
url_name="api-client-infos",
|
|
||||||
)
|
|
||||||
def get_client_info(self):
|
|
||||||
return self.context.request.auth
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from django.forms import HiddenInput
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
|
|
||||||
class ThirdPartyAuthForm(forms.Form):
|
|
||||||
"""Form to complete to authenticate on the sith from a third-party app.
|
|
||||||
|
|
||||||
For the form to be valid, the user approve the EULA (french: CGU)
|
|
||||||
and give its username from the third-party app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
cgu_accepted = forms.BooleanField(
|
|
||||||
required=True,
|
|
||||||
label=_("I have read and I accept the terms and conditions of use"),
|
|
||||||
error_messages={
|
|
||||||
"required": _("You must approve the terms and conditions of use.")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
is_username_valid = forms.BooleanField(
|
|
||||||
required=True,
|
|
||||||
error_messages={"required": _("You must confirm that this is your username.")},
|
|
||||||
)
|
|
||||||
client_id = forms.IntegerField(widget=HiddenInput())
|
|
||||||
third_party_app = forms.CharField(widget=HiddenInput())
|
|
||||||
privacy_link = forms.URLField(widget=HiddenInput())
|
|
||||||
username = forms.CharField(widget=HiddenInput())
|
|
||||||
callback_url = forms.URLField(widget=HiddenInput())
|
|
||||||
signature = forms.CharField(widget=HiddenInput())
|
|
||||||
|
|
||||||
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
|
|
||||||
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
|
|
||||||
self.fields["is_username_valid"].label = _(
|
|
||||||
"I confirm that %(username)s is my username on %(app)s"
|
|
||||||
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.3 on 2025-10-26 10:15
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import api.models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("api", "0001_initial")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="apiclient",
|
|
||||||
name="hmac_key",
|
|
||||||
field=models.CharField(
|
|
||||||
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+21
-32
@@ -1,20 +1,13 @@
|
|||||||
import secrets
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import pgettext_lazy
|
from django.utils.translation import pgettext_lazy
|
||||||
|
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
|
|
||||||
|
|
||||||
def get_hmac_key():
|
|
||||||
return secrets.token_hex(64)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient(models.Model):
|
class ApiClient(models.Model):
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
|
|||||||
help_text=_("Specific permissions for this api client."),
|
help_text=_("Specific permissions for this api client."),
|
||||||
related_name="clients",
|
related_name="clients",
|
||||||
)
|
)
|
||||||
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
_perm_cache: set[str] | None = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("api client")
|
verbose_name = _("api client")
|
||||||
verbose_name_plural = _("api clients")
|
verbose_name_plural = _("api clients")
|
||||||
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@cached_property
|
def has_perm(self, perm: str):
|
||||||
def all_permissions(self) -> set[str]:
|
"""Return True if the client has the specified permission."""
|
||||||
permissions = (
|
|
||||||
Permission.objects.filter(
|
if self._perm_cache is None:
|
||||||
Q(group__group__in=self.groups.all()) | Q(clients=self)
|
group_permissions = (
|
||||||
)
|
Permission.objects.filter(group__group__in=self.groups.all())
|
||||||
.values_list("content_type__app_label", "codename")
|
.values_list("content_type__app_label", "codename")
|
||||||
.order_by()
|
.order_by()
|
||||||
)
|
)
|
||||||
return {f"{content_type}.{name}" for content_type, name in permissions}
|
client_permissions = self.client_permissions.values_list(
|
||||||
|
"content_type__app_label", "codename"
|
||||||
|
).order_by()
|
||||||
|
self._perm_cache = {
|
||||||
|
f"{content_type}.{name}"
|
||||||
|
for content_type, name in (*group_permissions, *client_permissions)
|
||||||
|
}
|
||||||
|
return perm in self._perm_cache
|
||||||
|
|
||||||
def has_perm(self, perm: str):
|
def has_perms(self, perm_list):
|
||||||
"""Return True if the client has the specified permission."""
|
"""
|
||||||
return perm in self.all_permissions
|
Return True if the client has each of the specified permissions. If
|
||||||
|
object is passed, check if the client has all required perms for it.
|
||||||
def has_perms(self, perm_list: Iterable[str]) -> bool:
|
"""
|
||||||
"""Return True if the client has each of the specified permissions."""
|
|
||||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||||
raise ValueError("perm_list must be an iterable of permissions.")
|
raise ValueError("perm_list must be an iterable of permissions.")
|
||||||
return all(self.has_perm(perm) for perm in perm_list)
|
return all(self.has_perm(perm) for perm in perm_list)
|
||||||
|
|
||||||
def reset_hmac(self, *, commit: bool = True) -> str:
|
|
||||||
"""Reset and return the HMAC key for this client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
commit: if True (the default), persist the new hmac in db.
|
|
||||||
"""
|
|
||||||
self.hmac_key = get_hmac_key()
|
|
||||||
if commit:
|
|
||||||
self.save()
|
|
||||||
return self.hmac_key
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(models.Model):
|
class ApiKey(models.Model):
|
||||||
PREFIX_LENGTH = 5
|
PREFIX_LENGTH = 5
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
from ninja import ModelSchema, Schema
|
|
||||||
from pydantic import Field, HttpUrl
|
|
||||||
|
|
||||||
from api.models import ApiClient
|
|
||||||
from core.schemas import SimpleUserSchema
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientSchema(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = ApiClient
|
|
||||||
fields = ["id", "name"]
|
|
||||||
|
|
||||||
owner: SimpleUserSchema
|
|
||||||
permissions: list[str] = Field(alias="all_permissions")
|
|
||||||
|
|
||||||
|
|
||||||
class ThirdPartyAuthParamsSchema(Schema):
|
|
||||||
client_id: int
|
|
||||||
third_party_app: str
|
|
||||||
privacy_link: HttpUrl
|
|
||||||
username: str
|
|
||||||
callback_url: HttpUrl
|
|
||||||
signature: str
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
|
|
||||||
<p>
|
|
||||||
{% trans trimmed app=third_party_app %}
|
|
||||||
By ticking this box and clicking on the send button, you
|
|
||||||
acknowledge and agree to provide {{ app }} with your
|
|
||||||
first name, last name, nickname and any other information
|
|
||||||
that was the third party app was explicitly authorized to fetch
|
|
||||||
and that it must have acknowledged to you, in a complete and accurate manner.
|
|
||||||
{% endtrans %}
|
|
||||||
</p>
|
|
||||||
<p class="margin-bottom">
|
|
||||||
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
|
||||||
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
|
|
||||||
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
|
|
||||||
applies as soon as the form is submitted.
|
|
||||||
{% endtrans %}
|
|
||||||
</p>
|
|
||||||
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
|
|
||||||
<br>
|
|
||||||
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
|
|
||||||
<div class="row margin-bottom">
|
|
||||||
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
|
|
||||||
</div>
|
|
||||||
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
|
|
||||||
<input type="submit" class="btn btn-blue">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from django.contrib.admin import AdminSite
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from model_bakery import baker
|
|
||||||
from pytest_django.asserts import assertNumQueries
|
|
||||||
|
|
||||||
from api.admin import ApiClientAdmin
|
|
||||||
from api.models import ApiClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_reset_hmac_action():
|
|
||||||
client_admin = ApiClientAdmin(ApiClient, AdminSite())
|
|
||||||
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
|
|
||||||
old_hmac_keys = [c.hmac_key for c in api_clients]
|
|
||||||
with assertNumQueries(2):
|
|
||||||
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
|
|
||||||
client_admin.reset_hmac_key(HttpRequest(), qs)
|
|
||||||
for c in api_clients:
|
|
||||||
c.refresh_from_db()
|
|
||||||
assert api_clients[0].hmac_key == old_hmac_keys[0]
|
|
||||||
assert api_clients[1].hmac_key == old_hmac_keys[1]
|
|
||||||
assert api_clients[2].hmac_key != old_hmac_keys[2]
|
|
||||||
assert api_clients[3].hmac_key != old_hmac_keys[3]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from django.test import Client
|
|
||||||
from django.urls import reverse
|
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from api.hashers import generate_key
|
|
||||||
from api.models import ApiClient, ApiKey
|
|
||||||
from api.schemas import ApiClientSchema
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_api_client_controller(client: Client):
|
|
||||||
key, hashed = generate_key()
|
|
||||||
api_client = baker.make(ApiClient)
|
|
||||||
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
|
||||||
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.test import TestCase
|
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from api.models import ApiClient
|
|
||||||
from core.models import Group
|
|
||||||
|
|
||||||
|
|
||||||
class TestClientPermissions(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.api_client = baker.make(ApiClient)
|
|
||||||
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
|
|
||||||
cls.api_client.groups.set(
|
|
||||||
[
|
|
||||||
baker.make(Group, permissions=cls.perms[0:3]),
|
|
||||||
baker.make(Group, permissions=cls.perms[3:5]),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cls.api_client.client_permissions.set(
|
|
||||||
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_all_permissions(self):
|
|
||||||
assert self.api_client.all_permissions == {
|
|
||||||
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_has_perm(self):
|
|
||||||
assert self.api_client.has_perm(
|
|
||||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
|
|
||||||
)
|
|
||||||
assert not self.api_client.has_perm(
|
|
||||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_has_perms(self):
|
|
||||||
assert self.api_client.has_perms(
|
|
||||||
[
|
|
||||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
|
||||||
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert not self.api_client.has_perms(
|
|
||||||
[
|
|
||||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
|
||||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_reset_hmac_key():
|
|
||||||
client = baker.make(ApiClient)
|
|
||||||
original_key = client.hmac_key
|
|
||||||
client.reset_hmac(commit=True)
|
|
||||||
assert len(client.hmac_key) == len(original_key)
|
|
||||||
assert client.hmac_key != original_key
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from unittest import mock
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from django.contrib.messages import Message, get_messages
|
|
||||||
from django.db.models import Max
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from model_bakery import baker
|
|
||||||
from pytest_django.asserts import assertRedirects
|
|
||||||
|
|
||||||
from api.models import ApiClient, get_hmac_key
|
|
||||||
from core.baker_recipes import subscriber_user
|
|
||||||
from core.schemas import UserProfileSchema
|
|
||||||
from core.utils import hmac_hexdigest
|
|
||||||
|
|
||||||
|
|
||||||
def mocked_post(*, ok: bool):
|
|
||||||
class MockedResponse(Mock):
|
|
||||||
@property
|
|
||||||
def ok(self):
|
|
||||||
return ok
|
|
||||||
|
|
||||||
def mocked():
|
|
||||||
return MockedResponse()
|
|
||||||
|
|
||||||
return mocked
|
|
||||||
|
|
||||||
|
|
||||||
class TestThirdPartyAuth(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.user = subscriber_user.make()
|
|
||||||
cls.api_client = baker.make(ApiClient)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.query = {
|
|
||||||
"client_id": self.api_client.id,
|
|
||||||
"third_party_app": "app",
|
|
||||||
"privacy_link": "https://foobar.fr/",
|
|
||||||
"username": "bibou",
|
|
||||||
"callback_url": "https://callback.fr/",
|
|
||||||
}
|
|
||||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
|
||||||
self.callback_data = {
|
|
||||||
"user": UserProfileSchema.from_orm(self.user).model_dump()
|
|
||||||
}
|
|
||||||
self.callback_data["signature"] = hmac_hexdigest(
|
|
||||||
self.api_client.hmac_key, self.callback_data["user"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_auth_ok(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
|
||||||
assert res.status_code == 200
|
|
||||||
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("api-link:third-party-auth"),
|
|
||||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
|
||||||
)
|
|
||||||
mocked.assert_called_once_with(
|
|
||||||
self.query["callback_url"], json=self.callback_data
|
|
||||||
)
|
|
||||||
assertRedirects(
|
|
||||||
res,
|
|
||||||
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_callback_error(self):
|
|
||||||
"""Test that the user see the failure page if the callback request failed."""
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("api-link:third-party-auth"),
|
|
||||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
|
||||||
)
|
|
||||||
mocked.assert_called_once_with(
|
|
||||||
self.query["callback_url"], json=self.callback_data
|
|
||||||
)
|
|
||||||
assertRedirects(
|
|
||||||
res,
|
|
||||||
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_wrong_signature(self):
|
|
||||||
"""Test that a 403 is raised if the signature of the query is wrong."""
|
|
||||||
self.client.force_login(subscriber_user.make())
|
|
||||||
new_key = get_hmac_key()
|
|
||||||
del self.query["signature"]
|
|
||||||
self.query["signature"] = hmac_hexdigest(new_key, self.query)
|
|
||||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
|
||||||
assert list(get_messages(res.wsgi_request)) == [
|
|
||||||
Message(
|
|
||||||
level=40,
|
|
||||||
message=(
|
|
||||||
"La signature est incorrecte. "
|
|
||||||
"Nous ne pouvons pas garantir l'authenticité de la requête."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_cgu_not_accepted(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
|
||||||
assert res.status_code == 200
|
|
||||||
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
|
|
||||||
assert res.status_code == 200 # no redirect means invalid form
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("api-link:third-party-auth"),
|
|
||||||
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
def test_invalid_client(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
|
|
||||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
|
||||||
assert list(get_messages(res.wsgi_request)) == [
|
|
||||||
Message(
|
|
||||||
level=40,
|
|
||||||
message="Les données fournies pour l'authentification sont incorrectes.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_missing_parameter(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
del self.query["username"]
|
|
||||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
|
||||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
|
||||||
assert list(get_messages(res.wsgi_request)) == [
|
|
||||||
Message(
|
|
||||||
level=40,
|
|
||||||
message="Les données fournies pour l'authentification sont incorrectes.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
-15
@@ -1,10 +1,6 @@
|
|||||||
from django.urls import path, register_converter
|
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import NinjaExtraAPI
|
from ninja_extra import NinjaExtraAPI
|
||||||
|
|
||||||
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
|
|
||||||
from core.converters import ResultConverter
|
|
||||||
|
|
||||||
api = NinjaExtraAPI(
|
api = NinjaExtraAPI(
|
||||||
title="PICON",
|
title="PICON",
|
||||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||||
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
|
|||||||
auth=[SessionAuth()],
|
auth=[SessionAuth()],
|
||||||
)
|
)
|
||||||
api.auto_discover_controllers()
|
api.auto_discover_controllers()
|
||||||
|
|
||||||
register_converter(ResultConverter, "res")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
|
|
||||||
path(
|
|
||||||
"auth/<res:result>/",
|
|
||||||
ThirdPartyAuthResultView.as_view(),
|
|
||||||
name="third-party-auth-result",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
import hmac
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import pydantic
|
|
||||||
import requests
|
|
||||||
import sentry_sdk
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.urls import reverse, reverse_lazy
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from django.views.generic import FormView, TemplateView
|
|
||||||
from ninja_extra.shortcuts import get_object_or_none
|
|
||||||
|
|
||||||
from api.forms import ThirdPartyAuthForm
|
|
||||||
from api.models import ApiClient
|
|
||||||
from api.schemas import ThirdPartyAuthParamsSchema
|
|
||||||
from core.models import SithFile
|
|
||||||
from core.schemas import UserProfileSchema
|
|
||||||
from core.utils import hmac_hexdigest
|
|
||||||
|
|
||||||
|
|
||||||
class ThirdPartyAuthView(AccessMixin, FormView):
|
|
||||||
form_class = ThirdPartyAuthForm
|
|
||||||
template_name = "api/third_party/auth.jinja"
|
|
||||||
success_url = reverse_lazy("core:index")
|
|
||||||
|
|
||||||
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
|
|
||||||
"""Parse and check the authentication parameters.
|
|
||||||
|
|
||||||
If parsing fails, messages will be created using the django message
|
|
||||||
infrastructure.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parses parameters, or None if the parsing failed.
|
|
||||||
"""
|
|
||||||
# This is here rather than in ThirdPartyAuthForm because
|
|
||||||
# the given parameters and their signature are checked during both
|
|
||||||
# POST (for obvious reasons) and GET (in order not to make
|
|
||||||
# the user fill a form just to get an error he won't understand)
|
|
||||||
params = self.request.GET or self.request.POST
|
|
||||||
params = {key: unquote(val) for key, val in params.items()}
|
|
||||||
try:
|
|
||||||
params = ThirdPartyAuthParamsSchema(**params)
|
|
||||||
except pydantic.ValidationError:
|
|
||||||
messages.error(
|
|
||||||
self.request, _("The data provided for authentication is incorrect")
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
|
|
||||||
if not client:
|
|
||||||
messages.error(
|
|
||||||
self.request, _("The data provided for authentication is incorrect")
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if not hmac.compare_digest(
|
|
||||||
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
|
|
||||||
params.signature,
|
|
||||||
):
|
|
||||||
messages.error(
|
|
||||||
self.request,
|
|
||||||
_(
|
|
||||||
"The signature is incorrect. "
|
|
||||||
"We cannot ensure the provenance of the request."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return params
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return self.handle_no_permission()
|
|
||||||
self.params = self.parse_params()
|
|
||||||
if not self.params:
|
|
||||||
# if parameters parsing failed, shortcut the operation and display
|
|
||||||
# an empty page with just the error messages.
|
|
||||||
return render(request, "core/base.jinja")
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
_(
|
|
||||||
"You are going to link your AE account and your %(app)s account. "
|
|
||||||
"Continue only if this page was opened from %(app)s."
|
|
||||||
)
|
|
||||||
% {"app": self.params.third_party_app},
|
|
||||||
)
|
|
||||||
return super().get(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
return self.params.model_dump()
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
|
|
||||||
user = UserProfileSchema.from_orm(self.request.user).model_dump()
|
|
||||||
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
|
|
||||||
try:
|
|
||||||
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
|
|
||||||
except requests.RequestException as e:
|
|
||||||
sentry_sdk.capture_exception(e)
|
|
||||||
ok = False
|
|
||||||
self.success_url = reverse(
|
|
||||||
"api-link:third-party-auth-result",
|
|
||||||
kwargs={"result": "success" if ok else "failure"},
|
|
||||||
)
|
|
||||||
return super().form_valid(form)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return super().get_context_data(**kwargs) | {
|
|
||||||
"third_party_app": self.params.third_party_app,
|
|
||||||
"third_party_cgu": self.params.privacy_link,
|
|
||||||
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
|
|
||||||
"""View that the user will see if its authentication on sith was successful.
|
|
||||||
|
|
||||||
This can show either a success or a failure message :
|
|
||||||
- success : everything is good, the user is successfully authenticated
|
|
||||||
and can close the page
|
|
||||||
- failure : the authentication has been processed on the sith side,
|
|
||||||
but the request to the callback url received an error.
|
|
||||||
In such a case, there is nothing much we can do but to advice
|
|
||||||
the user to contact the developers of the third-party app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = "core/base.jinja"
|
|
||||||
success_message = _(
|
|
||||||
"You have been successfully authenticated. You can now close this page."
|
|
||||||
)
|
|
||||||
error_message = _(
|
|
||||||
"Your authentication on the AE website was successful, "
|
|
||||||
"but an error happened during the interaction "
|
|
||||||
"with the third-party application. "
|
|
||||||
"Please contact the managers of the latter."
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
if self.kwargs.get("result") == "success":
|
|
||||||
messages.success(request, self.success_message)
|
|
||||||
else:
|
|
||||||
messages.error(request, self.error_message)
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
+28
-1
@@ -13,8 +13,10 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.forms.models import ModelForm
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
@@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin):
|
|||||||
"page",
|
"page",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_model(
|
||||||
|
self,
|
||||||
|
request: HttpRequest,
|
||||||
|
obj: Club,
|
||||||
|
form: ModelForm,
|
||||||
|
change: bool, # noqa: FBT001
|
||||||
|
):
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
if not change:
|
||||||
|
obj.create_default_roles()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubRole)
|
||||||
|
class ClubRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "club", "is_board", "is_presidency")
|
||||||
|
search_fields = ("name",)
|
||||||
|
autocomplete_fields = ("club",)
|
||||||
|
list_select_related = ("club",)
|
||||||
|
list_filter = (
|
||||||
|
"is_board",
|
||||||
|
"is_presidency",
|
||||||
|
("club", admin.RelatedOnlyFieldListFilter),
|
||||||
|
)
|
||||||
|
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
+3
-2
@@ -37,7 +37,8 @@ class ClubController(ControllerBase):
|
|||||||
)
|
)
|
||||||
def fetch_club(self, club_id: int):
|
def fetch_club(self, club_id: int):
|
||||||
prefetch = Prefetch(
|
prefetch = Prefetch(
|
||||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
"members",
|
||||||
|
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
||||||
)
|
)
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related(prefetch), id=club_id
|
||||||
@@ -59,5 +60,5 @@ class UserClubController(ControllerBase):
|
|||||||
return (
|
return (
|
||||||
Membership.objects.ongoing()
|
Membership.objects.ongoing()
|
||||||
.filter(user=user)
|
.filter(user=user)
|
||||||
.select_related("club", "user")
|
.select_related("club", "user", "role")
|
||||||
)
|
)
|
||||||
|
|||||||
+79
-27
@@ -23,13 +23,12 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||||
from django.db.models import Exists, OuterRef, Q
|
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -215,9 +214,7 @@ class ClubOldMemberForm(forms.Form):
|
|||||||
|
|
||||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["members_old"].queryset = (
|
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
||||||
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberForm(forms.ModelForm):
|
class ClubMemberForm(forms.ModelForm):
|
||||||
@@ -235,19 +232,14 @@ class ClubMemberForm(forms.ModelForm):
|
|||||||
self.request_user = request_user
|
self.request_user = request_user
|
||||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["role"].required = True
|
self.fields["role"].queryset = self.available_roles
|
||||||
self.fields["role"].choices = [
|
|
||||||
(value, name)
|
|
||||||
for value, name in settings.SITH_CLUB_ROLES.items()
|
|
||||||
if value <= self.max_available_role
|
|
||||||
]
|
|
||||||
self.instance.club = club
|
self.instance.club = club
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_available_role(self):
|
def available_roles(self) -> QuerySet[ClubRole]:
|
||||||
"""The greatest role that will be obtainable with this form."""
|
"""The roles that will be obtainable with this form."""
|
||||||
# this is unreachable, because it will be overridden by subclasses
|
# this is unreachable, because it will be overridden by subclasses
|
||||||
return -1 # pragma: no cover
|
return ClubRole.objects.none() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class ClubAddMemberForm(ClubMemberForm):
|
class ClubAddMemberForm(ClubMemberForm):
|
||||||
@@ -258,21 +250,22 @@ class ClubAddMemberForm(ClubMemberForm):
|
|||||||
widgets = {"user": AutoCompleteSelectUser}
|
widgets = {"user": AutoCompleteSelectUser}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def max_available_role(self):
|
def available_roles(self):
|
||||||
"""The greatest role that will be obtainable with this form.
|
"""The roles that will be obtainable with this form.
|
||||||
|
|
||||||
Admins and the club president can attribute any role.
|
Admins and the club president can attribute any role.
|
||||||
Board members can attribute roles lower than their own.
|
Board members can attribute roles lower than their own.
|
||||||
Other users cannot attribute roles with this form
|
Other users cannot attribute roles with this form
|
||||||
"""
|
"""
|
||||||
|
qs = self.club.roles.filter(is_active=True)
|
||||||
if self.request_user.has_perm("club.add_membership"):
|
if self.request_user.has_perm("club.add_membership"):
|
||||||
return settings.SITH_CLUB_ROLES_ID["President"]
|
return qs.all()
|
||||||
membership = self.request_user_membership
|
membership = self.request_user_membership
|
||||||
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
if membership is None or not membership.role.is_board:
|
||||||
return -1
|
return ClubRole.objects.none()
|
||||||
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
if membership.role.is_presidency:
|
||||||
return membership.role
|
return qs.all()
|
||||||
return membership.role - 1
|
return qs.above_instance(membership.role)
|
||||||
|
|
||||||
def clean_user(self):
|
def clean_user(self):
|
||||||
"""Check that the user is not trying to add a user already in the club.
|
"""Check that the user is not trying to add a user already in the club.
|
||||||
@@ -296,13 +289,11 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
|
|
||||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||||
# this form doesn't manage the user who will join the club,
|
|
||||||
# so we must set this here to avoid errors
|
|
||||||
self.instance.user = self.request_user
|
self.instance.user = self.request_user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def max_available_role(self):
|
def available_roles(self):
|
||||||
return settings.SITH_MAXIMUM_FREE_ROLE
|
return self.club.roles.filter(is_board=False, is_active=True)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Check that the user is subscribed and isn't already in the club."""
|
"""Check that the user is subscribed and isn't already in the club."""
|
||||||
@@ -339,3 +330,64 @@ class ClubSearchForm(forms.ModelForm):
|
|||||||
# so we enforce it.
|
# so we enforce it.
|
||||||
self.fields["club_status"].value = True
|
self.fields["club_status"].value = True
|
||||||
self.fields["name"].required = False
|
self.fields["name"].required = False
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleForm(forms.ModelForm):
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
|
||||||
|
widgets = {
|
||||||
|
"is_presidency": forms.HiddenInput(),
|
||||||
|
"is_board": forms.HiddenInput(),
|
||||||
|
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if "ORDER" in cleaned_data:
|
||||||
|
self.instance.order = cleaned_data["ORDER"] - 1
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleCreateForm(forms.ModelForm):
|
||||||
|
"""Form to create a club role.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
For UX purposes, users are not meant to fill `is_presidency`
|
||||||
|
and `is_board`, so those values are required by the form constructor
|
||||||
|
in order to initialize the instance properly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["name", "description"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.instance.club = club
|
||||||
|
self.instance.is_presidency = is_presidency
|
||||||
|
self.instance.is_board = is_board
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
|
||||||
|
ordering_widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
|
||||||
|
ClubRoleFormSet = forms.inlineformset_factory(
|
||||||
|
Club,
|
||||||
|
ClubRole,
|
||||||
|
ClubRoleForm,
|
||||||
|
ClubRoleBaseFormSet,
|
||||||
|
can_delete=False,
|
||||||
|
can_order=True,
|
||||||
|
edit_only=True,
|
||||||
|
extra=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.functions.datetime
|
import django.db.models.functions.datetime
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.migrations.state import StateApps
|
from django.db.migrations.state import StateApps
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
|
|
||||||
|
# Before the club role rework, the maximum free role
|
||||||
|
# was the hardcoded highest non-board role
|
||||||
|
MAXIMUM_FREE_ROLE = 1
|
||||||
|
|
||||||
|
|
||||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||||
"""Attach the existing meta groups to the clubs.
|
"""Attach the existing meta groups to the clubs.
|
||||||
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
|||||||
).select_related("user")
|
).select_related("user")
|
||||||
club.members_group.users.set([m.user for m in memberships])
|
club.members_group.users.set([m.user for m in memberships])
|
||||||
club.board_group.users.set(
|
club.board_group.users.set(
|
||||||
[
|
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
||||||
m.user
|
|
||||||
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import Case, When
|
||||||
|
|
||||||
|
PRESIDENCY_ROLES = [10, 9]
|
||||||
|
MAXIMUM_FREE_ROLE = 1
|
||||||
|
SITH_CLUB_ROLES = {
|
||||||
|
10: "Président⸱e",
|
||||||
|
9: "Vice-Président⸱e",
|
||||||
|
7: "Trésorier⸱e",
|
||||||
|
5: "Responsable communication",
|
||||||
|
4: "Secrétaire",
|
||||||
|
3: "Responsable info",
|
||||||
|
2: "Membre du bureau",
|
||||||
|
1: "Membre actif⸱ve",
|
||||||
|
0: "Curieux⸱euse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_roles(apps: StateApps, schema_editor):
|
||||||
|
ClubRole = apps.get_model("club", "ClubRole")
|
||||||
|
Membership = apps.get_model("club", "Membership")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
||||||
|
new_role = ClubRole.objects.create(
|
||||||
|
name=SITH_CLUB_ROLES[role],
|
||||||
|
is_board=role > MAXIMUM_FREE_ROLE,
|
||||||
|
is_presidency=role in PRESIDENCY_ROLES,
|
||||||
|
club_id=club_id,
|
||||||
|
order=max(SITH_CLUB_ROLES) - role,
|
||||||
|
)
|
||||||
|
updates.append(When(role=role, then=new_role.id))
|
||||||
|
# all updates must happen at the same time
|
||||||
|
# otherwise, the 10 first created ClubRole would be
|
||||||
|
# re-modified after their initial creation, and it would
|
||||||
|
# result in an incoherent state.
|
||||||
|
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
||||||
|
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
||||||
|
Membership.objects.update(role=Case(*updates))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||||
|
("core", "0047_alter_notification_date_alter_notification_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="page",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.page",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ClubRole",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
db_index=True, editable=False, verbose_name="order"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"club",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="The club with which this role is associated",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="roles",
|
||||||
|
to="club.club",
|
||||||
|
verbose_name="club",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=50, verbose_name="name")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
default="", blank=True, verbose_name="description"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_board",
|
||||||
|
models.BooleanField(default=False, verbose_name="Board role"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_presidency",
|
||||||
|
models.BooleanField(default=False, verbose_name="Presidency role"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=(
|
||||||
|
"If the role is inactive, people joining the club "
|
||||||
|
"won't be able to get it."
|
||||||
|
),
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("order",),
|
||||||
|
"verbose_name": "club role",
|
||||||
|
"verbose_name_plural": "club roles",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="board_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
editable=False,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club_board",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="members_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
editable=False,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="clubrole",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(
|
||||||
|
("is_presidency", False), ("is_board", True), _connector="OR"
|
||||||
|
),
|
||||||
|
name="clubrole_presidency_implies_board",
|
||||||
|
violation_error_message=(
|
||||||
|
"A role cannot be in the presidency while not being in the board"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
||||||
|
# because Postgres migrations run in a single transaction,
|
||||||
|
# we cannot change the actual values of Membership.role
|
||||||
|
# and apply the FOREIGN KEY constraint in the same migration.
|
||||||
|
# The constraint is created in the next migration
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# because Postgres migrations run in a single transaction,
|
||||||
|
# we cannot change the actual values of Membership.role
|
||||||
|
# and apply the FOREIGN KEY constraint in the same migration.
|
||||||
|
# The data migration was made in the previous migration.
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="membership",
|
||||||
|
name="role",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="members",
|
||||||
|
to="club.clubrole",
|
||||||
|
verbose_name="role",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+162
-29
@@ -28,15 +28,15 @@ from typing import Iterable, Self
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
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
|
||||||
from django.db import models, transaction
|
from django.db import ProgrammingError, models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
from django.db.models import Exists, F, OuterRef, Q
|
||||||
from django.db.models.functions import Greatest
|
|
||||||
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.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 ordered_model.models import OrderedModel
|
||||||
|
|
||||||
from core.fields import ResizedImageField
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, Page, SithFile, User
|
from core.models import Group, Notification, Page, SithFile, User
|
||||||
@@ -89,13 +89,13 @@ 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, on_delete=models.CASCADE
|
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
members_group = models.OneToOneField(
|
members_group = models.OneToOneField(
|
||||||
Group, related_name="club", on_delete=models.PROTECT
|
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
||||||
)
|
)
|
||||||
board_group = models.OneToOneField(
|
board_group = models.OneToOneField(
|
||||||
Group, related_name="club_board", on_delete=models.PROTECT
|
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ClubQuerySet.as_manager()
|
objects = ClubQuerySet.as_manager()
|
||||||
@@ -138,9 +138,7 @@ class Club(models.Model):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def president(self) -> Membership | None:
|
def president(self) -> Membership | None:
|
||||||
"""Fetch the membership of the current president of this club."""
|
"""Fetch the membership of the current president of this club."""
|
||||||
return self.members.filter(
|
return self.members.filter(end_date=None).order_by("role__order").first()
|
||||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
|
||||||
).first()
|
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
"""Raise a validation error when a loop is found within the parent list."""
|
"""Raise a validation error when a loop is found within the parent list."""
|
||||||
@@ -185,6 +183,40 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
|
def create_default_roles(self):
|
||||||
|
"""Create some roles that should exist by default for this club.
|
||||||
|
|
||||||
|
The created roles are : president, treasurer, active member and curious.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
When calling this method, no club must exist yet for this club.
|
||||||
|
"""
|
||||||
|
if self.roles.exists():
|
||||||
|
raise ProgrammingError(
|
||||||
|
"Default roles can be created only for clubs "
|
||||||
|
"that don't have associated roles yet"
|
||||||
|
)
|
||||||
|
# The names are written in French, because there is no gettext involved
|
||||||
|
# for strings stored in database, and the majority of users are french.
|
||||||
|
roles = [
|
||||||
|
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
||||||
|
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
||||||
|
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
||||||
|
ClubRole(
|
||||||
|
name="Curieux⸱euse",
|
||||||
|
description=(
|
||||||
|
"Les gens qui suivent l'activité "
|
||||||
|
"du club sans forcément y participer"
|
||||||
|
),
|
||||||
|
is_board=False,
|
||||||
|
is_presidency=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
role.club = self
|
||||||
|
role.order = i
|
||||||
|
ClubRole.objects.bulk_create(roles)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
self.board_group.delete()
|
self.board_group.delete()
|
||||||
self.members_group.delete()
|
self.members_group.delete()
|
||||||
@@ -206,9 +238,20 @@ class Club(models.Model):
|
|||||||
"""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."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
|
def can_roles_be_edited_by(self, user: User) -> bool:
|
||||||
|
"""Return True if the given user can edit the roles of this club"""
|
||||||
|
return user.is_authenticated and (
|
||||||
|
user.has_perm("club.change_clubrole")
|
||||||
|
or self.members.ongoing()
|
||||||
|
.filter(user=user, role__is_presidency=True)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def current_members(self) -> list[Membership]:
|
def current_members(self) -> list[Membership]:
|
||||||
return list(self.members.ongoing().select_related("user").order_by("-role"))
|
return list(
|
||||||
|
self.members.ongoing().select_related("user", "role").order_by("-role")
|
||||||
|
)
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership of the given user."""
|
"""Return the current membership of the given user."""
|
||||||
@@ -220,6 +263,95 @@ class Club(models.Model):
|
|||||||
return user.is_in_group(pk=self.board_group_id)
|
return user.is_in_group(pk=self.board_group_id)
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRole(OrderedModel):
|
||||||
|
club = models.ForeignKey(
|
||||||
|
Club,
|
||||||
|
verbose_name=_("club"),
|
||||||
|
help_text=_("The club with which this role is associated"),
|
||||||
|
related_name="roles",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
name = models.CharField(_("name"), max_length=50)
|
||||||
|
description = models.TextField(_("description"), blank=True, default="")
|
||||||
|
is_board = models.BooleanField(_("Board role"), default=False)
|
||||||
|
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
_("is active"),
|
||||||
|
default=True,
|
||||||
|
help_text=_(
|
||||||
|
"If the role is inactive, people joining the club won't be able to get it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
order_with_respect_to = "club"
|
||||||
|
|
||||||
|
class Meta(OrderedModel.Meta):
|
||||||
|
verbose_name = _("club role")
|
||||||
|
verbose_name_plural = _("club roles")
|
||||||
|
constraints = [
|
||||||
|
# presidency IMPLIES board <=> NOT presidency OR board
|
||||||
|
# cf. MT1 :)
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(is_presidency=False) | Q(is_board=True),
|
||||||
|
name="clubrole_presidency_implies_board",
|
||||||
|
violation_error_message=_(
|
||||||
|
"A role cannot be in the presidency while not being in the board"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_display_name(self):
|
||||||
|
return f"{self.name} - {self.club.name}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
errors = []
|
||||||
|
roles = list(self.club.roles.all())
|
||||||
|
if (
|
||||||
|
self.is_board
|
||||||
|
and self.order
|
||||||
|
and any(r.order < self.order and not r.is_board for r in roles)
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
ValidationError(
|
||||||
|
_("Role %(role)s cannot be placed below a member role")
|
||||||
|
% {"role": self.name}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.is_presidency
|
||||||
|
and self.order
|
||||||
|
and any(r.order < self.order and not r.is_presidency for r in roles)
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
ValidationError(
|
||||||
|
_("Role %(role)s cannot be placed below a non-presidency role")
|
||||||
|
% {"role": self.name}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
return super().clean()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
auto_order = self.order is None and self.is_board
|
||||||
|
if not auto_order:
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
return
|
||||||
|
# get the role that should be placed after the role we are dealing with.
|
||||||
|
# So, if this is role is presidency, get the first board role ;
|
||||||
|
# if it is a board role, get the first member role ;
|
||||||
|
# and if it is a member role, get nothing (OrderedModel.save will
|
||||||
|
# automatically put it in the last position anyway)
|
||||||
|
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
||||||
|
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if next_role:
|
||||||
|
self.above(next_role)
|
||||||
|
|
||||||
|
|
||||||
class MembershipQuerySet(models.QuerySet):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
def ongoing(self) -> Self:
|
def ongoing(self) -> Self:
|
||||||
"""Filter all memberships which are not finished yet."""
|
"""Filter all memberships which are not finished yet."""
|
||||||
@@ -232,9 +364,10 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
are included, even if there are no more members.
|
are included, even if there are no more members.
|
||||||
|
|
||||||
If you want to get the users who are currently in the board,
|
If you want to get the users who are currently in the board,
|
||||||
mind combining this with the `ongoing` queryset method
|
mind combining this with the [MembershipQuerySet.ongoing][]
|
||||||
|
queryset method
|
||||||
"""
|
"""
|
||||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
return self.filter(role__is_board=True)
|
||||||
|
|
||||||
def editable_by(self, user: User) -> Self:
|
def editable_by(self, user: User) -> Self:
|
||||||
"""Filter Memberships that this user can edit.
|
"""Filter Memberships that this user can edit.
|
||||||
@@ -257,21 +390,16 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
if user.has_perm("club.change_membership"):
|
if user.has_perm("club.change_membership"):
|
||||||
return self.all()
|
return self.all()
|
||||||
return self.filter(
|
return self.ongoing().filter(
|
||||||
Q(user=user)
|
Q(user=user)
|
||||||
| Exists(
|
| Exists(
|
||||||
Membership.objects.filter(
|
Membership.objects.ongoing().filter(
|
||||||
Q(
|
|
||||||
role__gt=Greatest(
|
|
||||||
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
user=user,
|
user=user,
|
||||||
end_date=None,
|
|
||||||
club=OuterRef("club"),
|
club=OuterRef("club"),
|
||||||
|
role__is_board=True,
|
||||||
|
role__order__lt=OuterRef("role__order"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
|
||||||
end_date=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update(self, **kwargs) -> int:
|
def update(self, **kwargs) -> int:
|
||||||
@@ -341,10 +469,11 @@ class Membership(models.Model):
|
|||||||
)
|
)
|
||||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||||
role = models.IntegerField(
|
role = models.ForeignKey(
|
||||||
_("role"),
|
ClubRole,
|
||||||
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
verbose_name=_("role"),
|
||||||
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
related_name="members",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
_("description"), max_length=128, null=False, blank=True
|
_("description"), max_length=128, null=False, blank=True
|
||||||
@@ -362,7 +491,7 @@ class Membership(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
f"- {self.role.name} "
|
||||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -391,7 +520,11 @@ class Membership(models.Model):
|
|||||||
if user.is_root or user.is_board_member:
|
if user.is_root or user.is_board_member:
|
||||||
return True
|
return True
|
||||||
membership = self.club.get_membership_for(user)
|
membership = self.club.get_membership_for(user)
|
||||||
return membership is not None and membership.role >= self.role
|
if not membership:
|
||||||
|
return False
|
||||||
|
return membership.user_id == user.id or (
|
||||||
|
membership.is_board and membership.role.order < self.role.order
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
@@ -467,7 +600,7 @@ class Membership(models.Model):
|
|||||||
group_id=membership.club.members_group_id,
|
group_id=membership.club.members_group_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
if membership.role.is_board:
|
||||||
club_groups.append(
|
club_groups.append(
|
||||||
User.groups.through(
|
User.groups.through(
|
||||||
user_id=membership.user_id,
|
user_id=membership.user_id,
|
||||||
|
|||||||
+11
-3
@@ -3,7 +3,7 @@ from typing import Annotated
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
@@ -39,14 +39,21 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["id", "name", "is_presidency", "is_board"]
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberSchema(ModelSchema):
|
class ClubMemberSchema(ModelSchema):
|
||||||
"""A schema to represent all memberships in a club."""
|
"""A schema to represent all memberships in a club."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["start_date", "end_date", "role", "description"]
|
fields = ["start_date", "end_date", "description"]
|
||||||
|
|
||||||
user: SimpleUserSchema
|
user: SimpleUserSchema
|
||||||
|
role: ClubRoleSchema
|
||||||
|
|
||||||
|
|
||||||
class ClubSchema(ModelSchema):
|
class ClubSchema(ModelSchema):
|
||||||
@@ -62,6 +69,7 @@ class UserMembershipSchema(ModelSchema):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["id", "start_date", "role", "description"]
|
fields = ["id", "start_date", "description"]
|
||||||
|
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
|
role: ClubRoleSchema
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { AlpineComponent } from "alpinejs";
|
||||||
|
|
||||||
|
interface RoleGroupData {
|
||||||
|
isBoard: boolean;
|
||||||
|
isPresidency: boolean;
|
||||||
|
roleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
|
||||||
|
confirmOnSubmit: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit relevant item data after it has been moved by x-sort
|
||||||
|
*/
|
||||||
|
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
|
||||||
|
item.isBoard = conf.isBoard;
|
||||||
|
item.isPresidency = conf.isPresidency;
|
||||||
|
// if the user has moved its own role outside the presidency,
|
||||||
|
// submitting the form will require a confirmation
|
||||||
|
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
|
||||||
|
this.resetOrder();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reset the value of the ORDER input of all items in the list.
|
||||||
|
* This is to be called after any reordering operation, in order to make sure
|
||||||
|
* that the order that will be saved is coherent with what is displayed.
|
||||||
|
*/
|
||||||
|
resetOrder() {
|
||||||
|
// When moving items with x-sort, the only information we truly have is
|
||||||
|
// the end position in the target group, not the previous position nor
|
||||||
|
// the position in the global list.
|
||||||
|
// To overcome this, we loop through an enumeration of all inputs
|
||||||
|
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||||
|
"input[name^='roles'][name$='ORDER']",
|
||||||
|
);
|
||||||
|
for (const [i, elem] of inputs.entries()) {
|
||||||
|
elem.value = (i + 1).toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user moved its role out of the presidency, ask a confirmation
|
||||||
|
* before submitting the form
|
||||||
|
*/
|
||||||
|
confirmSubmission(event: SubmitEvent) {
|
||||||
|
if (
|
||||||
|
this.confirmOnSubmit &&
|
||||||
|
!confirm(
|
||||||
|
gettext(
|
||||||
|
"You're going to remove your own role from the presidency. " +
|
||||||
|
"You may lock yourself out of this page. Do you want to continue ? ",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.fa-grip-vertical {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
@@ -26,10 +26,9 @@
|
|||||||
{% if club.logo %}
|
{% if club.logo %}
|
||||||
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<h3>{{ club.name }}</h3>
|
||||||
{% if page_revision %}
|
{% if page_revision %}
|
||||||
{{ page_revision|markdown }}
|
{{ page_revision|markdown }}
|
||||||
{% else %}
|
|
||||||
<h3>{{ club.name }}</h3>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{% if is_fragment %}
|
{% if is_fragment %}
|
||||||
{% extends "core/base_fragment.jinja" %}
|
{% extends "core/base_fragment.jinja" %}
|
||||||
|
|
||||||
|
{% block metatags %}
|
||||||
|
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Liste des clubs et assos" />
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{# Don't display tabs and errors #}
|
{# Don't display tabs and errors #}
|
||||||
{% block tabs %}
|
{% block tabs %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
|
|
||||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
<h2>{% trans %}Club members{% endtrans %}</h2>
|
||||||
|
|
||||||
|
{% if club.can_roles_be_edited_by(user) %}
|
||||||
|
<a
|
||||||
|
href="{{ url("club:club_roles", club_id=object.id) }}"
|
||||||
|
class="btn btn-blue margin-bottom"
|
||||||
|
>
|
||||||
|
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if add_member_fragment %}
|
{% if add_member_fragment %}
|
||||||
<br />
|
<br />
|
||||||
{{ add_member_fragment }}
|
{{ add_member_fragment }}
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
{% for m in members %}
|
{% for m in members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(m.user) }}</td>
|
<td>{{ user_profile_link(m.user) }}</td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
<td>{{ m.role.name }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{%- if can_end_membership -%}
|
{%- if can_end_membership -%}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{% for member in old_members %}
|
{% for member in old_members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(member.user) }}</td>
|
<td>{{ user_profile_link(member.user) }}</td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
<td>{{ member.role.name }}</td>
|
||||||
<td>{{ member.description }}</td>
|
<td>{{ member.description }}</td>
|
||||||
<td>{{ member.start_date }}</td>
|
<td>{{ member.start_date }}</td>
|
||||||
<td>{{ member.end_date }}</td>
|
<td>{{ member.end_date }}</td>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro display_subform(subform) %}
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
x-data="{
|
||||||
|
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||||
|
isBoard: {{ subform.is_board.value()|lower }},
|
||||||
|
roleId: {{ subform.id.value() }},
|
||||||
|
}"
|
||||||
|
x-sort:item="$data"
|
||||||
|
>
|
||||||
|
{# hidden fields #}
|
||||||
|
{{ subform.ORDER }}
|
||||||
|
{{ subform.id }}
|
||||||
|
{{ subform.club }}
|
||||||
|
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||||
|
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||||
|
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||||
|
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
{{ subform.name.value() }}
|
||||||
|
{% if not subform.instance.is_active -%}
|
||||||
|
({% trans %}inactive{% endtrans %})
|
||||||
|
{%- endif %}
|
||||||
|
</summary>
|
||||||
|
<div class="accordion-content">
|
||||||
|
{{ subform.non_field_errors() }}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.name.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.description.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div>
|
||||||
|
{{ subform.is_active }}
|
||||||
|
{{ subform.is_active.label_tag() }}
|
||||||
|
</div>
|
||||||
|
<span class="helptext">
|
||||||
|
{{ subform.is_active.help_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
Roles give rights on the club.
|
||||||
|
Higher roles grant more rights, and the members having them are displayed higher
|
||||||
|
in the club members list.
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
On this page, you can edit their name and description, as well as their order.
|
||||||
|
You can also drag roles from a category to another
|
||||||
|
(e.g. a board role can be made into a presidency role).
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
|
||||||
|
@submit="confirmSubmission"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.management_form }}
|
||||||
|
{{ form.non_form_errors() }}
|
||||||
|
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
{# The style we use for markdown rendering is quite nice for what we want to display,
|
||||||
|
so we are just gonna reuse it. #}
|
||||||
|
<div class="markdown">
|
||||||
|
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
|
||||||
|
<li>{% trans %}manage the club counters{% endtrans %}</li>
|
||||||
|
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
<div class="markdown">
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
Board members can do most administrative actions in the club, including :
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans %}manage the club posters{% endtrans %}</li>
|
||||||
|
<li>{% trans %}create news for the club{% endtrans %}</li>
|
||||||
|
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
|
||||||
|
<li>
|
||||||
|
{% trans trimmed %}
|
||||||
|
add new members and end active memberships
|
||||||
|
for roles that are lower than their own.
|
||||||
|
{% endtrans %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
<div class="markdown">
|
||||||
|
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if not subform.is_board.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
<button type="submit" class="btn btn-blue">
|
||||||
|
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
@@ -5,8 +5,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
<li>
|
||||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||||
|
{% trans %}Create a news{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||||
|
{% trans %}Post in the Weekmail{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if object.can_roles_be_edited_by(user) %}
|
||||||
|
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||||
|
{% endif %}
|
||||||
{% if object.trombi %}
|
{% if object.trombi %}
|
||||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
+15
-5
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ class TestClub(TestCase):
|
|||||||
|
|
||||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
|
cls.president_role = baker.make(
|
||||||
|
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
||||||
|
)
|
||||||
|
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
||||||
|
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
||||||
cls.new_members_url = reverse(
|
cls.new_members_url = reverse(
|
||||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||||
)
|
)
|
||||||
@@ -51,12 +56,17 @@ class TestClub(TestCase):
|
|||||||
yesterday = now() - timedelta(days=1)
|
yesterday = now() - timedelta(days=1)
|
||||||
membership_recipe = Recipe(Membership, club=cls.club)
|
membership_recipe = Recipe(Membership, club=cls.club)
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
||||||
|
)
|
||||||
|
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
||||||
|
membership_recipe.make(
|
||||||
|
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
||||||
)
|
)
|
||||||
membership_recipe.make(user=cls.richard, role=1)
|
|
||||||
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
|
|
||||||
membership_recipe.make( # sli was a member but isn't anymore
|
membership_recipe.make( # sli was a member but isn't anymore
|
||||||
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
user=cls.sli,
|
||||||
|
start_date=a_month_ago,
|
||||||
|
end_date=yesterday,
|
||||||
|
role=cls.board_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
+91
-5
@@ -1,13 +1,16 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import ProgrammingError
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -19,11 +22,19 @@ def test_club_queryset_having_board_member():
|
|||||||
membership_recipe = Recipe(
|
membership_recipe = Recipe(
|
||||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||||
)
|
)
|
||||||
membership_recipe.make(club=clubs[0], role=1)
|
|
||||||
membership_recipe.make(club=clubs[1], role=3)
|
|
||||||
membership_recipe.make(club=clubs[2], role=7)
|
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[3],
|
||||||
|
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
||||||
|
end_date=localdate() - timedelta(days=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||||
@@ -39,3 +50,78 @@ def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
|||||||
headers = {"HX-Request": True} if is_fragment else {}
|
headers = {"HX-Request": True} if is_fragment else {}
|
||||||
res = client.get(reverse("club:club_list"), headers=headers)
|
res = client.get(reverse("club:club_list"), headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def assert_club_created(club_name: str):
|
||||||
|
club = Club.objects.last()
|
||||||
|
assert club.name == club_name
|
||||||
|
assert club.board_group.name == f"{club_name} - Bureau"
|
||||||
|
assert club.members_group.name == f"{club_name} - Membres"
|
||||||
|
# default roles should be added on club creation,
|
||||||
|
# whether the creation happens on the admin site or on the user site
|
||||||
|
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
||||||
|
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
||||||
|
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
||||||
|
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
||||||
|
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_view(admin_client: Client):
|
||||||
|
"""Test that the club creation view works well"""
|
||||||
|
res = admin_client.get(reverse("club:club_new"))
|
||||||
|
assert res.status_code == 200
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("club:club_new"),
|
||||||
|
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
||||||
|
)
|
||||||
|
club = Club.objects.last()
|
||||||
|
assertRedirects(res, club.get_absolute_url())
|
||||||
|
assert_club_created("foo")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_default_roles_for_club_with_roles_fails():
|
||||||
|
"""Test that an Error is raised if trying to create
|
||||||
|
default roles for a club that already has roles.
|
||||||
|
"""
|
||||||
|
club = baker.make(Club)
|
||||||
|
baker.make(ClubRole, club=club)
|
||||||
|
with pytest.raises(ProgrammingError):
|
||||||
|
club.create_default_roles()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestAdminInterface:
|
||||||
|
def test_create(self, admin_client: Client):
|
||||||
|
"""Test the creation of a club via the admin interface."""
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("admin:club_club_add"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"parent": settings.SITH_MAIN_CLUB_ID,
|
||||||
|
"address": "Rome",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||||
|
assert_club_created("foo")
|
||||||
|
|
||||||
|
def test_change(self, admin_client: Client):
|
||||||
|
"""Test the edition of a club via the admin interface."""
|
||||||
|
club = baker.make(Club)
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"page": club.page_id,
|
||||||
|
"home": club.home_id,
|
||||||
|
"address": club.address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||||
|
club.refresh_from_db()
|
||||||
|
assert club.name == "foo"
|
||||||
|
# Club roles shouldn't be modified when editing the club on the admin interface
|
||||||
|
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
||||||
|
assert not club.roles.exists()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -8,7 +9,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Page, User
|
from core.models import Group, Page, User
|
||||||
|
|
||||||
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
|
|||||||
"id", flat=True
|
"id", flat=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Page.objects.exclude(club=None).delete()
|
Membership.objects.all().delete()
|
||||||
|
ClubRole.objects.all().delete()
|
||||||
Club.objects.all().delete()
|
Club.objects.all().delete()
|
||||||
|
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
||||||
Group.objects.filter(id__in=groups).delete()
|
Group.objects.filter(id__in=groups).delete()
|
||||||
|
|
||||||
cls.clubs = baker.make(
|
cls.clubs = baker.make(
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker, seq
|
||||||
|
from model_bakery.recipe import Recipe
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
from club.forms import ClubRoleFormSet
|
||||||
|
from club.models import Club, ClubRole, Membership
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import AnonymousUser, User
|
||||||
|
|
||||||
|
|
||||||
|
def make_club():
|
||||||
|
# unittest-style tests cannot use fixture, so we create a function
|
||||||
|
# that will be callable either by a pytest fixture or inside
|
||||||
|
# a TestCase.setUpTestData method.
|
||||||
|
club = baker.make(Club)
|
||||||
|
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
||||||
|
recipe.make(
|
||||||
|
is_board=iter([True, True, False]),
|
||||||
|
is_presidency=iter([True, False, False]),
|
||||||
|
order=iter([0, 1, 2]),
|
||||||
|
_quantity=3,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
return club
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def club(db):
|
||||||
|
"""A club with a presidency role, a board role and a member role"""
|
||||||
|
return make_club()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_auto(club):
|
||||||
|
"""Test that newly created roles are put in the right place."""
|
||||||
|
roles = list(club.roles.all())
|
||||||
|
# create new roles one by one (like they will be in prod)
|
||||||
|
# each new role should be placed at the end of its category
|
||||||
|
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
|
||||||
|
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
||||||
|
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
||||||
|
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
||||||
|
assert list(club.roles.order_by("order")) == [
|
||||||
|
roles[0],
|
||||||
|
role_a,
|
||||||
|
roles[1],
|
||||||
|
role_b,
|
||||||
|
roles[2],
|
||||||
|
role_c,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("user_factory", "is_allowed"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
lambda club: baker.make(
|
||||||
|
User,
|
||||||
|
user_permissions=[Permission.objects.get(codename="change_clubrole")],
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
( # user with presidency roles can edit the club roles
|
||||||
|
lambda club: subscriber_user.make(
|
||||||
|
memberships=[
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
role=club.roles.filter(is_presidency=True).first(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
( # user in the board but not in the presidency cannot edit roles
|
||||||
|
lambda club: subscriber_user.make(
|
||||||
|
memberships=[
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
role=club.roles.filter(
|
||||||
|
is_presidency=False, is_board=True
|
||||||
|
).first(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(lambda _: AnonymousUser(), False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_can_roles_be_edited_by(
|
||||||
|
club: Club, user_factory: Callable[[Club], User], is_allowed
|
||||||
|
):
|
||||||
|
"""Test that `Club.can_roles_be_edited_by` return the right value"""
|
||||||
|
user = user_factory(club)
|
||||||
|
assert club.can_roles_be_edited_by(user) == is_allowed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["route", "is_presidency", "is_board"],
|
||||||
|
[
|
||||||
|
("club:new_role_president", True, True),
|
||||||
|
("club:new_role_board", False, True),
|
||||||
|
("club:new_role_member", False, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
|
||||||
|
"""Test that the role creation views work."""
|
||||||
|
club = baker.make(Club)
|
||||||
|
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
|
||||||
|
user = subscriber_user.make()
|
||||||
|
baker.make(Membership, club=club, role=role, user=user, end_date=None)
|
||||||
|
url = reverse(route, kwargs={"club_id": club.id})
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
res = client.get(url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
res = client.post(url, data={"name": "foo"})
|
||||||
|
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
|
||||||
|
new_role = club.roles.last()
|
||||||
|
assert new_role.name == "foo"
|
||||||
|
assert new_role.is_presidency == is_presidency
|
||||||
|
assert new_role.is_board == is_board
|
||||||
|
|
||||||
|
|
||||||
|
class TestClubRoleUpdate(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
cls.roles = list(cls.club.roles.all())
|
||||||
|
cls.user = subscriber_user.make()
|
||||||
|
baker.make(
|
||||||
|
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
|
||||||
|
)
|
||||||
|
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
|
||||||
|
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.payload = {
|
||||||
|
"roles-TOTAL_FORMS": 3,
|
||||||
|
"roles-INITIAL_FORMS": 3,
|
||||||
|
"roles-MIN_NUM_FORMS": 0,
|
||||||
|
"roles-MAX_NUM_FORMS": 1000,
|
||||||
|
"roles-0-ORDER": 1,
|
||||||
|
"roles-0-id": self.roles[0].id,
|
||||||
|
"roles-0-club": self.club.id,
|
||||||
|
"roles-0-is_presidency": True,
|
||||||
|
"roles-0-is_board": True,
|
||||||
|
"roles-0-name": self.roles[0].name,
|
||||||
|
"roles-0-description": self.roles[0].description,
|
||||||
|
"roles-0-is_active": True,
|
||||||
|
"roles-1-ORDER": 2,
|
||||||
|
"roles-1-id": self.roles[1].id,
|
||||||
|
"roles-1-club": self.club.id,
|
||||||
|
"roles-1-is_presidency": False,
|
||||||
|
"roles-1-is_board": True,
|
||||||
|
"roles-1-name": self.roles[1].name,
|
||||||
|
"roles-1-description": self.roles[1].description,
|
||||||
|
"roles-1-is_active": True,
|
||||||
|
"roles-2-ORDER": 3,
|
||||||
|
"roles-2-id": self.roles[2].id,
|
||||||
|
"roles-2-club": self.club.id,
|
||||||
|
"roles-2-is_presidency": False,
|
||||||
|
"roles-2-is_board": False,
|
||||||
|
"roles-2-name": self.roles[2].name,
|
||||||
|
"roles-2-description": self.roles[2].description,
|
||||||
|
"roles-2-is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_view_ok(self):
|
||||||
|
"""Basic test to check that the view works."""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
self.payload["roles-2-name"] = "foo"
|
||||||
|
res = self.client.post(self.url, data=self.payload)
|
||||||
|
assertRedirects(res, self.redirect_url)
|
||||||
|
self.roles[2].refresh_from_db()
|
||||||
|
assert self.roles[2].name == "foo"
|
||||||
|
|
||||||
|
def test_incoherent_order(self):
|
||||||
|
"""Test that placing a member role over a board role fails."""
|
||||||
|
self.payload["roles-0-ORDER"] = 4
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert not formset.is_valid()
|
||||||
|
assert formset.errors == [
|
||||||
|
{
|
||||||
|
"__all__": [
|
||||||
|
f"Le rôle {self.roles[0].name} ne peut pas "
|
||||||
|
"être placé en-dessous d'un rôle de membre.",
|
||||||
|
f"Le rôle {self.roles[0].name} ne peut pas être placé "
|
||||||
|
"en-dessous d'un rôle qui n'est pas de la présidence.",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_change_order_ok(self):
|
||||||
|
"""Test that changing order the intended way works"""
|
||||||
|
self.payload["roles-1-ORDER"] = 3
|
||||||
|
self.payload["roles-1-is_board"] = False
|
||||||
|
self.payload["roles-2-ORDER"] = 2
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert formset.is_valid()
|
||||||
|
formset.save()
|
||||||
|
assert list(self.club.roles.order_by("order")) == [
|
||||||
|
self.roles[0],
|
||||||
|
self.roles[2],
|
||||||
|
self.roles[1],
|
||||||
|
]
|
||||||
|
self.roles[1].refresh_from_db()
|
||||||
|
assert not self.roles[1].is_board
|
||||||
|
|
||||||
|
def test_non_board_presidency_is_forbidden(self):
|
||||||
|
"""Test that a role cannot be in the presidency without being in the board."""
|
||||||
|
self.payload["roles-0-is_board"] = False
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert not formset.is_valid()
|
||||||
|
assert formset.errors == [
|
||||||
|
{
|
||||||
|
"__all__": [
|
||||||
|
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_president_moves_itself_out_of_the_presidency(self):
|
||||||
|
"""Test that if the user moves its own role out of the presidency,
|
||||||
|
then it's redirected to another page and loses access to the update page."""
|
||||||
|
self.payload["roles-0-is_presidency"] = False
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
res = self.client.post(self.url, data=self.payload)
|
||||||
|
assertRedirects(res, self.redirect_url)
|
||||||
|
# When the user clicked that button, it still had the right to update roles,
|
||||||
|
# so the modification should be applied
|
||||||
|
self.roles[0].refresh_from_db()
|
||||||
|
assert self.roles[0].is_presidency is False
|
||||||
|
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert res.status_code == 403
|
||||||
+13
-3
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +12,12 @@ from core.baker_recipes import subscriber_user
|
|||||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||||
baker.make(Membership, club=club, user=user, role=7)
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
user=user,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.post(
|
res = client.post(
|
||||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||||
@@ -32,7 +37,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
|||||||
"""crash test for club:club_edit"""
|
"""crash test for club:club_edit"""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=club, user=user, role=3)
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
user=user,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, Mailing, Membership
|
from club.models import Club, ClubRole, Mailing, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
|
|||||||
user=cls.rbatsbak,
|
user=cls.rbatsbak,
|
||||||
club=cls.club,
|
club=cls.club,
|
||||||
start_date=timezone.now(),
|
start_date=timezone.now(),
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def test_mailing_list_add_no_moderation(self):
|
def test_mailing_list_add_no_moderation(self):
|
||||||
|
|||||||
+171
-57
@@ -1,20 +1,20 @@
|
|||||||
|
import itertools
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from model_bakery import baker
|
from model_bakery import baker, seq
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from club.tests.base import TestClub
|
from club.tests.base import TestClub
|
||||||
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
|
||||||
@@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_update_change_club_groups(self):
|
def test_update_change_club_groups(self):
|
||||||
"""Test that `update` set the user groups accordingly."""
|
"""Test that `update` set the user groups accordingly."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
board_role, member_role = baker.make(
|
||||||
|
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
||||||
|
)
|
||||||
|
membership = baker.make(
|
||||||
|
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
||||||
|
)
|
||||||
members_group = membership.club.members_group
|
members_group = membership.club.members_group
|
||||||
board_group = membership.club.board_group
|
board_group = membership.club.board_group
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=1) # from board to simple member
|
user.memberships.update(role=member_role) # from board to simple member
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=5) # from member to board
|
user.memberships.update(role=board_role) # from member to board
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
@@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_delete_remove_from_groups(self):
|
def test_delete_remove_from_groups(self):
|
||||||
"""Test that `delete` removes from club groups"""
|
"""Test that `delete` removes from club groups"""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
club = baker.make(Club)
|
||||||
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
is_board=iter([False, True]),
|
||||||
|
club=club,
|
||||||
|
_quantity=2,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
memberships = baker.make(
|
||||||
|
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
||||||
|
)
|
||||||
club_groups = {
|
club_groups = {
|
||||||
memberships[0].club.members_group,
|
memberships[0].club.members_group,
|
||||||
memberships[1].club.members_group,
|
memberships[1].club.members_group,
|
||||||
@@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
Membership.objects.all().delete()
|
Membership.objects.all().delete()
|
||||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||||
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
is_presidency=itertools.cycle([True, False, False, False]),
|
||||||
|
is_board=itertools.cycle([True, True, True, False]),
|
||||||
|
order=itertools.cycle(range(4)),
|
||||||
|
club=iter(
|
||||||
|
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
||||||
|
),
|
||||||
|
_quantity=8,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
cls.memberships = [
|
cls.memberships = [
|
||||||
*baker.make(
|
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
||||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
|
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
||||||
),
|
|
||||||
*baker.make(
|
|
||||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_admin_user(self):
|
def test_admin_user(self):
|
||||||
@@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMembership(TestClub):
|
class TestMembership(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: int):
|
def assert_membership_started_today(self, user: User, role: ClubRole):
|
||||||
"""Assert that the given membership is active and started today."""
|
"""Assert that the given membership is active and started today."""
|
||||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||||
assert membership is not None
|
assert membership is not None
|
||||||
@@ -189,21 +211,27 @@ class TestMembership(TestClub):
|
|||||||
"Marquer comme ancien",
|
"Marquer comme ancien",
|
||||||
]
|
]
|
||||||
rows = table.find("tbody").find_all("tr")
|
rows = table.find("tbody").find_all("tr")
|
||||||
memberships = self.club.members.ongoing().order_by("-role")
|
memberships = (
|
||||||
for row, membership in zip(
|
self.club.members.ongoing()
|
||||||
rows, memberships.select_related("user"), strict=False
|
.order_by("role__order")
|
||||||
):
|
.select_related("user", "role")
|
||||||
|
)
|
||||||
|
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||||
|
for row, membership in zip(rows, memberships, strict=False):
|
||||||
user = membership.user
|
user = membership.user
|
||||||
user_url = reverse("core:user_profile", args=[user.id])
|
user_url = reverse("core:user_profile", args=[user.id])
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
user_link = cols[0].find("a")
|
user_link = cols[0].find("a")
|
||||||
assert user_link.attrs["href"] == user_url
|
assert user_link.attrs["href"] == user_url
|
||||||
assert user_link.text == user.get_display_name()
|
assert user_link.text == user.get_display_name()
|
||||||
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
assert cols[1].text == membership.role.name
|
||||||
assert cols[2].text == membership.description
|
assert cols[2].text == membership.description
|
||||||
assert cols[3].text == str(membership.start_date)
|
assert cols[3].text == str(membership.start_date)
|
||||||
|
|
||||||
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
if (
|
||||||
|
membership.role.order > user_role.order
|
||||||
|
or membership.user_id == self.simple_board_member.id
|
||||||
|
):
|
||||||
# 3 is the role of simple_board_member
|
# 3 is the role of simple_board_member
|
||||||
form_input = cols[4].find("input")
|
form_input = cols[4].find("input")
|
||||||
expected_attrs = {
|
expected_attrs = {
|
||||||
@@ -219,14 +247,15 @@ class TestMembership(TestClub):
|
|||||||
"""Test that root users can add members to clubs"""
|
"""Test that root users can add members to clubs"""
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
self.new_members_url,
|
||||||
|
{"user": self.subscriber.id, "role": self.board_role.id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
"club:club_members", kwargs={"club_id": self.club.id}
|
"club:club_members", kwargs={"club_id": self.club.id}
|
||||||
)
|
)
|
||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
self.assert_membership_started_today(self.subscriber, role=3)
|
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
||||||
|
|
||||||
def test_add_unauthorized_members(self):
|
def test_add_unauthorized_members(self):
|
||||||
"""Test that users who are not currently subscribed
|
"""Test that users who are not currently subscribed
|
||||||
@@ -234,7 +263,7 @@ class TestMembership(TestClub):
|
|||||||
"""
|
"""
|
||||||
for user in self.public, self.old_subscriber:
|
for user in self.public, self.old_subscriber:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": user.id, "role": 1},
|
data={"user": user.id, "role": self.member_role},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -255,7 +284,7 @@ class TestMembership(TestClub):
|
|||||||
nb_memberships = self.simple_board_member.memberships.count()
|
nb_memberships = self.simple_board_member.memberships.count()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
{"users": self.simple_board_member.id, "role": self.member_role},
|
||||||
)
|
)
|
||||||
self.simple_board_member.refresh_from_db()
|
self.simple_board_member.refresh_from_db()
|
||||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||||
@@ -274,7 +303,7 @@ class TestMembership(TestClub):
|
|||||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": members, "role": 1},
|
data={"user": members, "role": self.member_role},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -290,12 +319,13 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_president_add_members(self):
|
def test_president_add_members(self):
|
||||||
"""Test that the president of the club can add members."""
|
"""Test that the president of the club can add members."""
|
||||||
president = self.club.members.get(role=10).user
|
president = self.club.members.get(role=self.president_role).user
|
||||||
nb_club_membership = self.club.members.count()
|
nb_club_membership = self.club.members.count()
|
||||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||||
self.client.force_login(president)
|
self.client.force_login(president)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url, {"user": self.subscriber.id, "role": 9}
|
self.new_members_url,
|
||||||
|
{"user": self.subscriber.id, "role": self.president_role.id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
@@ -305,14 +335,17 @@ class TestMembership(TestClub):
|
|||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
assert self.club.members.count() == nb_club_membership + 1
|
assert self.club.members.count() == nb_club_membership + 1
|
||||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||||
self.assert_membership_started_today(self.subscriber, role=9)
|
self.assert_membership_started_today(self.subscriber, role=self.president_role)
|
||||||
|
|
||||||
def test_add_member_greater_role(self):
|
def test_add_member_greater_role(self):
|
||||||
"""Test that a member of the club member cannot create
|
"""Test that a member of the club member cannot create
|
||||||
a membership with a greater role than its own.
|
a membership with a greater role than its own.
|
||||||
"""
|
"""
|
||||||
|
user_role = self.simple_board_member.memberships.first().role
|
||||||
|
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
|
||||||
|
other_role.above(user_role)
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.subscriber.id, "role": 10},
|
data={"user": self.subscriber.id, "role": other_role.id},
|
||||||
request_user=self.simple_board_member,
|
request_user=self.simple_board_member,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -320,7 +353,10 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
"role": [
|
||||||
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
assert nb_memberships == self.club.members.count()
|
assert nb_memberships == self.club.members.count()
|
||||||
@@ -336,8 +372,9 @@ class TestMembership(TestClub):
|
|||||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||||
|
|
||||||
def test_add_member_already_there(self):
|
def test_add_member_already_there(self):
|
||||||
|
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.simple_board_member, "role": 3},
|
data={"user": self.simple_board_member, "role": role.id},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -348,22 +385,27 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_add_other_member_forbidden(self):
|
def test_add_other_member_forbidden(self):
|
||||||
non_member = subscriber_user.make()
|
non_member = subscriber_user.make()
|
||||||
simple_member = baker.make(Membership, club=self.club, role=1).user
|
simple_member = baker.make(
|
||||||
|
Membership, club=self.club, role=self.member_role
|
||||||
|
).user
|
||||||
for user in non_member, simple_member:
|
for user in non_member, simple_member:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": subscriber_user.make(), "role": 1},
|
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
||||||
request_user=user,
|
request_user=user,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
"role": [
|
||||||
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_simple_members_dont_see_form_anymore(self):
|
def test_simple_members_dont_see_form_anymore(self):
|
||||||
"""Test that simple club members don't see the form to add members"""
|
"""Test that simple club members don't see the form to add members"""
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=self.club, user=user, role=1)
|
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
res = self.client.get(self.members_url)
|
res = self.client.get(self.members_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -382,9 +424,10 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club can end memberships
|
"""Test that board members of the club can end memberships
|
||||||
of users with lower roles.
|
of users with lower roles.
|
||||||
"""
|
"""
|
||||||
# reminder : simple_board_member has role 3
|
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
role = baker.make(ClubRole, club=self.club, is_board=True)
|
||||||
|
role.below(self.board_role)
|
||||||
|
membership = baker.make(Membership, club=self.club, role=role)
|
||||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.assertRedirects(response, self.members_url)
|
self.assertRedirects(response, self.members_url)
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -394,7 +437,9 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club cannot end memberships
|
"""Test that board members of the club cannot end memberships
|
||||||
of users with higher roles.
|
of users with higher roles.
|
||||||
"""
|
"""
|
||||||
membership = self.president.memberships.filter(club=self.club).first()
|
membership = self.president.memberships.filter(
|
||||||
|
club=self.club, end_date=None
|
||||||
|
).first()
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -436,7 +481,9 @@ class TestMembership(TestClub):
|
|||||||
def test_remove_from_club_group(self):
|
def test_remove_from_club_group(self):
|
||||||
"""Test that when a membership ends, the user is removed from club groups."""
|
"""Test that when a membership ends, the user is removed from club groups."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
baker.make(
|
||||||
|
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
||||||
|
)
|
||||||
assert user.groups.contains(self.club.members_group)
|
assert user.groups.contains(self.club.members_group)
|
||||||
assert user.groups.contains(self.club.board_group)
|
assert user.groups.contains(self.club.board_group)
|
||||||
user.memberships.update(end_date=localdate())
|
user.memberships.update(end_date=localdate())
|
||||||
@@ -447,18 +494,20 @@ class TestMembership(TestClub):
|
|||||||
"""Test that when a membership begins, the user is added to the club group."""
|
"""Test that when a membership begins, the user is added to the club group."""
|
||||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
baker.make(
|
||||||
|
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||||
|
)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
def test_change_position_in_club(self):
|
def test_change_position_in_club(self):
|
||||||
"""Test that when moving from board to members, club group change"""
|
"""Test that when moving from board to members, club group change"""
|
||||||
membership = baker.make(
|
membership = baker.make(
|
||||||
Membership, club=self.club, user=self.subscriber, role=3
|
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||||
)
|
)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
membership.role = 1
|
membership.role = self.member_role
|
||||||
membership.save()
|
membership.save()
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
@@ -471,7 +520,11 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
# make sli a board member
|
# make sli a board member
|
||||||
self.sli.memberships.all().delete()
|
self.sli.memberships.all().delete()
|
||||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
Membership(
|
||||||
|
club=self.ae,
|
||||||
|
user=self.sli,
|
||||||
|
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
||||||
|
).save()
|
||||||
assert self.club.is_owned_by(self.sli)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
def test_change_club_name(self):
|
def test_change_club_name(self):
|
||||||
@@ -497,7 +550,7 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_membership_set_old(client: Client):
|
def test_membership_set_old(client: Client):
|
||||||
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
|
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
||||||
client.force_login(membership.user)
|
client.force_login(membership.user)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||||
@@ -524,6 +577,50 @@ def test_membership_delete(client: Client):
|
|||||||
assert not Membership.objects.filter(id=membership.id).exists()
|
assert not Membership.objects.filter(id=membership.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestAddMemberForm(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = baker.make(Club)
|
||||||
|
cls.roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
club=cls.club,
|
||||||
|
is_board=iter([True, True, True, True, False, False]),
|
||||||
|
is_presidency=iter([True, True, False, False, False, False]),
|
||||||
|
order=seq(0),
|
||||||
|
_quantity=6,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
cls.roles[-1].is_active = False
|
||||||
|
cls.roles[-1].save()
|
||||||
|
|
||||||
|
def test_admin(self):
|
||||||
|
"""Test that admin users can give any active role."""
|
||||||
|
user = baker.make(
|
||||||
|
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||||
|
)
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||||
|
|
||||||
|
def test_president(self):
|
||||||
|
"""Test that someone with a presidency role can give any active role."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||||
|
|
||||||
|
def test_board_member(self):
|
||||||
|
"""Test that someone with a board role can give lower active role."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
||||||
|
|
||||||
|
def test_simple_member(self):
|
||||||
|
"""Test that someone with a non-board role cannot give roles."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestJoinClub:
|
class TestJoinClub:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -531,55 +628,64 @@ class TestJoinClub:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("user_factory", "role", "errors"),
|
("user_factory", "board_role", "errors"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
subscriber_user.make,
|
subscriber_user.make,
|
||||||
2,
|
True,
|
||||||
{
|
{
|
||||||
"role": [
|
"role": [
|
||||||
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
1,
|
False,
|
||||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_join_club_errors(
|
def test_join_club_errors(
|
||||||
self, user_factory: Callable[[], User], role: int, errors: dict
|
self, user_factory: Callable[[], User], board_role, errors: dict
|
||||||
):
|
):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
role = baker.make(ClubRole, club=club, is_board=board_role)
|
||||||
|
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == errors
|
assert form.errors == errors
|
||||||
|
|
||||||
def test_user_already_in_club(self):
|
def test_user_already_in_club(self):
|
||||||
club = baker.make(Club)
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, user=user, club=club)
|
role = baker.make(ClubRole, is_board=False)
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
baker.make(Membership, user=user, club=role.club)
|
||||||
|
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||||
|
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
club = baker.make(Club)
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
role = baker.make(ClubRole, is_board=False)
|
||||||
|
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
form.save()
|
form.save()
|
||||||
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestOldMembersView(TestCase):
|
class TestOldMembersView(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
club=club,
|
||||||
|
is_board=itertools.cycle([True, True, False]),
|
||||||
|
order=seq(0),
|
||||||
|
_quantity=10,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
cls.memberships = baker.make(
|
cls.memberships = baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
role=iter(roles),
|
role=iter(roles),
|
||||||
@@ -604,3 +710,11 @@ class TestOldMembersView(TestCase):
|
|||||||
self.client.force_login(baker.make(User))
|
self.client.force_login(baker.make(User))
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
def test_context_data(self):
|
||||||
|
# mark a membership as not ended, to make sure it is excluded from the result
|
||||||
|
self.memberships[0].end_date = None
|
||||||
|
self.memberships[0].save()
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import PageRev, User
|
from core.models import PageRev, User
|
||||||
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
|
|||||||
def test_edit_page(client: Client):
|
def test_edit_page(client: Client):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, user=user, club=club, role=3)
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
user=user,
|
||||||
|
club=club,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||||
content = "# foo\nLorem ipsum dolor sit amet"
|
content = "# foo\nLorem ipsum dolor sit amet"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from club.schemas import UserMembershipSchema
|
from club.schemas import UserMembershipSchema
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Page
|
from core.models import Page
|
||||||
@@ -19,7 +19,10 @@ class TestFetchClub(TestCase):
|
|||||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||||
recipe = Recipe(
|
recipe = Recipe(
|
||||||
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
|
Membership,
|
||||||
|
user=cls.user,
|
||||||
|
start_date=localdate() - timedelta(days=2),
|
||||||
|
role=baker.make(ClubRole),
|
||||||
)
|
)
|
||||||
cls.members = Membership.objects.bulk_create(
|
cls.members = Membership.objects.bulk_create(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ from club.views import (
|
|||||||
ClubPageEditView,
|
ClubPageEditView,
|
||||||
ClubPageHistView,
|
ClubPageHistView,
|
||||||
ClubRevView,
|
ClubRevView,
|
||||||
|
ClubRoleBoardCreateView,
|
||||||
|
ClubRoleMemberCreateView,
|
||||||
|
ClubRolePresidencyCreateView,
|
||||||
|
ClubRoleUpdateView,
|
||||||
ClubSellingCSVView,
|
ClubSellingCSVView,
|
||||||
ClubSellingView,
|
ClubSellingView,
|
||||||
ClubToolsView,
|
ClubToolsView,
|
||||||
@@ -71,6 +75,22 @@ urlpatterns = [
|
|||||||
ClubOldMembersView.as_view(),
|
ClubOldMembersView.as_view(),
|
||||||
name="club_old_members",
|
name="club_old_members",
|
||||||
),
|
),
|
||||||
|
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/president/",
|
||||||
|
ClubRolePresidencyCreateView.as_view(),
|
||||||
|
name="new_role_president",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/board/",
|
||||||
|
ClubRoleBoardCreateView.as_view(),
|
||||||
|
name="new_role_board",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/member/",
|
||||||
|
ClubRoleMemberCreateView.as_view(),
|
||||||
|
name="new_role_member",
|
||||||
|
),
|
||||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||||
path(
|
path(
|
||||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||||
|
|||||||
+136
-15
@@ -28,8 +28,11 @@ import csv
|
|||||||
import itertools
|
import itertools
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.contrib.auth.mixins import (
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UserPassesTestMixin,
|
||||||
|
)
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
||||||
from django.core.paginator import InvalidPage, Paginator
|
from django.core.paginator import InvalidPage, Paginator
|
||||||
@@ -56,12 +59,14 @@ from club.forms import (
|
|||||||
ClubAdminEditForm,
|
ClubAdminEditForm,
|
||||||
ClubEditForm,
|
ClubEditForm,
|
||||||
ClubOldMemberForm,
|
ClubOldMemberForm,
|
||||||
|
ClubRoleCreateForm,
|
||||||
|
ClubRoleFormSet,
|
||||||
ClubSearchForm,
|
ClubSearchForm,
|
||||||
JoinClubForm,
|
JoinClubForm,
|
||||||
MailingForm,
|
MailingForm,
|
||||||
SellingsForm,
|
SellingsForm,
|
||||||
)
|
)
|
||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||||
from com.models import Poster
|
from com.models import Poster
|
||||||
from com.views import (
|
from com.views import (
|
||||||
PosterCreateBaseView,
|
PosterCreateBaseView,
|
||||||
@@ -210,15 +215,15 @@ class ClubListView(AllowFragment, FormMixin, ListView):
|
|||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
res = super().get_form_kwargs()
|
res = super().get_form_kwargs()
|
||||||
if self.request.method == "GET":
|
# if request.GET is empty, the form will interpret club_status as None,
|
||||||
res |= {"data": self.request.GET, "initial": self.request.GET}
|
# even though we want it to be initially True,
|
||||||
|
# so we force a defaut True value.
|
||||||
|
res["data"] = {"club_status": True} | self.request.GET.dict()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
form: ClubSearchForm = self.get_form()
|
form: ClubSearchForm = self.get_form()
|
||||||
qs = self.queryset
|
qs = self.queryset
|
||||||
if not form.is_bound:
|
|
||||||
return qs.filter(is_active=True)
|
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
return qs.none()
|
return qs.none()
|
||||||
if name := form.cleaned_data.get("name"):
|
if name := form.cleaned_data.get("name"):
|
||||||
@@ -355,7 +360,7 @@ class ClubMembersView(
|
|||||||
membership = self.object.get_membership_for(self.request.user)
|
membership = self.object.get_membership_for(self.request.user)
|
||||||
if (
|
if (
|
||||||
membership
|
membership
|
||||||
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
and not membership.role.is_board
|
||||||
and not self.request.user.has_perm("club.add_membership")
|
and not self.request.user.has_perm("club.add_membership")
|
||||||
):
|
):
|
||||||
# Simple club members won't see the form anymore.
|
# Simple club members won't see the form anymore.
|
||||||
@@ -380,8 +385,8 @@ class ClubMembersView(
|
|||||||
kwargs["members"] = list(
|
kwargs["members"] = list(
|
||||||
self.object.members.ongoing()
|
self.object.members.ongoing()
|
||||||
.annotate(is_editable=Q(id__in=editable))
|
.annotate(is_editable=Q(id__in=editable))
|
||||||
.order_by("-role")
|
.order_by("role__order")
|
||||||
.select_related("user")
|
.select_related("user", "role")
|
||||||
)
|
)
|
||||||
kwargs["can_end_membership"] = len(editable) > 0
|
kwargs["can_end_membership"] = len(editable) > 0
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -409,12 +414,125 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
|||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"old_members": (
|
"old_members": (
|
||||||
self.object.members.exclude(end_date=None)
|
self.object.members.exclude(end_date=None)
|
||||||
.order_by("-role", "description", "-end_date")
|
.order_by("role__order", "description", "-end_date")
|
||||||
.select_related("user")
|
.select_related("user", "role")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleUpdateView(
|
||||||
|
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
|
||||||
|
):
|
||||||
|
form_class = ClubRoleFormSet
|
||||||
|
model = Club
|
||||||
|
template_name = "club/club_roles.jinja"
|
||||||
|
pk_url_kwarg = "club_id"
|
||||||
|
current_tab = "members"
|
||||||
|
success_message = _("Club roles updated")
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def club(self) -> Club:
|
||||||
|
return self.get_object()
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.club.can_roles_be_edited_by(self.request.user)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"user_role": ClubRole.objects.filter(
|
||||||
|
club=self.club,
|
||||||
|
members__user=self.request.user,
|
||||||
|
members__end_date=None,
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
|
||||||
|
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
|
||||||
|
|
||||||
|
This view isn't meant to be called directly, but rather subclassed for each
|
||||||
|
type of role that can exist :
|
||||||
|
|
||||||
|
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
|
||||||
|
to create a presidency role
|
||||||
|
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
|
||||||
|
to create a board role
|
||||||
|
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
|
||||||
|
to create a member role
|
||||||
|
|
||||||
|
Each subclass have to override the following variables :
|
||||||
|
|
||||||
|
- `is_presidency` and `is_board`, indicating what type of role
|
||||||
|
the view creates.
|
||||||
|
- `role_description`, which is the title of the page, indication
|
||||||
|
the user what kind of role is being created.
|
||||||
|
|
||||||
|
This way, we are making sure the correct type of role will
|
||||||
|
be created, without bothering the user with the implementation details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
form_class = ClubRoleCreateForm
|
||||||
|
model = ClubRole
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
success_message = _("Role %(name)s created")
|
||||||
|
role_description = ""
|
||||||
|
is_presidency: bool
|
||||||
|
is_board: bool
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def club(self):
|
||||||
|
return get_object_or_404(Club, id=self.kwargs["club_id"])
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.request.user.is_authenticated and (
|
||||||
|
self.request.user.has_perm("club.add_clubrole")
|
||||||
|
or self.club.members.filter(
|
||||||
|
user=self.request.user, role__is_presidency=True
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {
|
||||||
|
"club": self.club,
|
||||||
|
"is_presidency": self.is_presidency,
|
||||||
|
"is_board": self.is_board,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"object_name": self.role_description
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = True
|
||||||
|
is_board = True
|
||||||
|
role_description = _("club role \u2013 presidency")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = False
|
||||||
|
is_board = True
|
||||||
|
role_description = _("club role \u2013 board")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = False
|
||||||
|
is_board = False
|
||||||
|
role_description = _("club role \u2013 member")
|
||||||
|
|
||||||
|
|
||||||
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||||
"""Sales of a club."""
|
"""Sales of a club."""
|
||||||
|
|
||||||
@@ -581,6 +699,11 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "club.add_club"
|
permission_required = "club.add_club"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
res = super().form_valid(form)
|
||||||
|
self.object.create_default_roles()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||||
"""Set a membership as being old."""
|
"""Set a membership as being old."""
|
||||||
@@ -761,9 +884,7 @@ class MailingAutoGenerationView(View):
|
|||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
club = self.mailing.club
|
club = self.mailing.club
|
||||||
self.mailing.subscriptions.all().delete()
|
self.mailing.subscriptions.all().delete()
|
||||||
members = club.members.filter(
|
members = club.members.ongoing().filter(role__is_board=True)
|
||||||
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
|
||||||
).exclude(end_date__lte=timezone.now())
|
|
||||||
for member in members.all():
|
for member in members.all():
|
||||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||||
return redirect("club:mailing", club_id=club.id)
|
return redirect("club:mailing", club_id=club.id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#news {
|
#news {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -26,12 +27,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
background: $second-color;
|
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
background: lighten($second-color, 5%);
|
||||||
padding: 0.4em;
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: .75rem;
|
||||||
margin: 0 0 0.5em 0;
|
margin: 0 0 0.5em 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
margin: 2em 0 1em 0;
|
margin: 2em 0 1em 0;
|
||||||
@@ -39,12 +42,11 @@
|
|||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
float: right;
|
float: right;
|
||||||
color: #f26522;
|
color: #e25512;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
@media screen and (max-width: $small-devices) {
|
||||||
|
|
||||||
#left_column,
|
#left_column,
|
||||||
#right_column {
|
#right_column {
|
||||||
flex: 100%;
|
flex: 100%;
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
#load-more-news-button {
|
#load-more-news-button {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -76,15 +79,11 @@
|
|||||||
font-size: 70%;
|
font-size: 70%;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#links_content {
|
#links_content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
min-height: 20em;
|
min-height: 20em;
|
||||||
padding-bottom: 1em;
|
padding: 1em;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -121,6 +120,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#birthdays_content {
|
#birthdays_content {
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
padding: 1em;
|
||||||
ul.birthdays_year {
|
ul.birthdays_year {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
@@ -135,8 +136,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: .5em 0 0 1em;
|
||||||
margin-left: 1em;
|
|
||||||
list-style-type: square;
|
list-style-type: square;
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -150,9 +150,13 @@
|
|||||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||||
.news_events_group {
|
.news_events_group {
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
margin-left: 1em;
|
margin-left: 0;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
|
@media screen and (max-width: $small-devices) {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.news_events_group_date {
|
.news_events_group_date {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
||||||
</h3>
|
</h3>
|
||||||
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
||||||
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
|
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
|
||||||
<i class="fa fa-plus"></i>
|
<i class="fa fa-plus"></i>
|
||||||
{% trans %}Create news{% endtrans %}
|
{% trans %}Create news{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
||||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="slideshow([
|
<body x-data="slideshow([
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Notification, User
|
from core.models import Group, Notification, SithFile, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -18,6 +18,7 @@ def test_notification_created():
|
|||||||
past_news = baker.make(News, is_published=False)
|
past_news = baker.make(News, is_published=False)
|
||||||
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
||||||
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
|
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
|
||||||
com_admin_group.users.all().delete()
|
com_admin_group.users.all().delete()
|
||||||
Notification.objects.all().delete()
|
Notification.objects.all().delete()
|
||||||
com_admin = baker.make(User, groups=[com_admin_group])
|
com_admin = baker.make(User, groups=[com_admin_group])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, Group, User
|
||||||
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
||||||
|
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|||||||
+1
-1
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
|||||||
self.object = form.instance
|
self.object = form.instance
|
||||||
form.is_valid() # Valid a first time to populate club field
|
form.is_valid() # Valid a first time to populate club field
|
||||||
m = form.instance.club.get_membership_for(request.user)
|
m = form.instance.club.get_membership_for(request.user)
|
||||||
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
if m is None or not m.role.is_board:
|
||||||
form.add_error(
|
form.add_error(
|
||||||
"club",
|
"club",
|
||||||
ValidationError(
|
ValidationError(
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
|
|||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_group(self, search: Annotated[str, MinLen(1)]):
|
def search_group(self, search: Annotated[str, MinLen(1)]):
|
||||||
return Group.objects.filter(name__icontains=search).values()
|
return Group.objects.filter(name__icontains=search).order_by("name").values()
|
||||||
|
|
||||||
|
|
||||||
DepthValue = Annotated[int, Ge(0), Le(10)]
|
DepthValue = Annotated[int, Ge(0), Le(10)]
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import localdate, now
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, foreign_key, related
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import ClubRole, Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=foreign_key(
|
||||||
|
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
board_user = Recipe(
|
board_user = Recipe(
|
||||||
|
|||||||
+8
-11
@@ -1,16 +1,19 @@
|
|||||||
from django.urls.converters import IntConverter, StringConverter
|
class FourDigitYearConverter:
|
||||||
|
|
||||||
|
|
||||||
class FourDigitYearConverter(IntConverter):
|
|
||||||
regex = "[0-9]{4}"
|
regex = "[0-9]{4}"
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return str(value).zfill(4)
|
return str(value).zfill(4)
|
||||||
|
|
||||||
|
|
||||||
class TwoDigitMonthConverter(IntConverter):
|
class TwoDigitMonthConverter:
|
||||||
regex = "[0-9]{2}"
|
regex = "[0-9]{2}"
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return str(value).zfill(2)
|
return str(value).zfill(2)
|
||||||
|
|
||||||
@@ -25,9 +28,3 @@ class BooleanStringConverter:
|
|||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
class ResultConverter(StringConverter):
|
|
||||||
"""Converter whose regex match either "success" or "failure"."""
|
|
||||||
|
|
||||||
regex = "(success|failure)"
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
@@ -37,12 +36,19 @@ 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 club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from com.ics_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, ReturnableProduct, StudentCard
|
from counter.models import (
|
||||||
|
Counter,
|
||||||
|
Price,
|
||||||
|
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 UE
|
from pedagogy.models import UE
|
||||||
@@ -63,6 +69,13 @@ class PopulatedGroups(NamedTuple):
|
|||||||
campus_admin: Group
|
campus_admin: Group
|
||||||
|
|
||||||
|
|
||||||
|
class PopulatedClubs(NamedTuple):
|
||||||
|
ae: Club
|
||||||
|
troll: Club
|
||||||
|
pdf: Club
|
||||||
|
refound: Club
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@@ -105,49 +118,25 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
|
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
|
||||||
home_root = SithFile.objects.create(name="users", owner=root)
|
home_root = SithFile.objects.create(name="users", owner=root)
|
||||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
|
||||||
sas = SithFile.objects.create(
|
|
||||||
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
|
||||||
)
|
|
||||||
SithFile.objects.create(
|
|
||||||
name="CGU",
|
|
||||||
is_folder=False,
|
|
||||||
file=ContentFile(
|
|
||||||
content="Conditions générales d'utilisation", name="cgu.txt"
|
|
||||||
),
|
|
||||||
owner=root,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Page needed for club creation
|
# Page needed for club creation
|
||||||
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||||
p.save(force_lock=True)
|
p.save(force_lock=True)
|
||||||
|
|
||||||
main_club = Club.objects.create(
|
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
sas = SithFile.objects.create(
|
||||||
)
|
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
||||||
main_club.board_group.permissions.add(
|
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=[
|
|
||||||
"view_subscription",
|
|
||||||
"add_subscription",
|
|
||||||
"view_hidden_user",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bar_club = Club.objects.create(
|
|
||||||
id=settings.SITH_PDF_CLUB_ID,
|
|
||||||
name="PdF",
|
|
||||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
|
||||||
)
|
)
|
||||||
|
clubs = self._create_clubs()
|
||||||
|
|
||||||
self.reset_index("club")
|
self.reset_index("club")
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
counters = [
|
counters = [
|
||||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
||||||
]
|
]
|
||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
@@ -330,178 +319,55 @@ class Command(BaseCommand):
|
|||||||
self._create_subscription(tutu)
|
self._create_subscription(tutu)
|
||||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||||
|
|
||||||
# Clubs
|
Membership.objects.create(
|
||||||
Club.objects.create(
|
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
||||||
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
|
||||||
)
|
)
|
||||||
guyut = Club.objects.create(
|
|
||||||
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
|
|
||||||
)
|
|
||||||
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
|
|
||||||
troll = Club.objects.create(
|
|
||||||
name="Troll Penché", address="Terre Du Milieu", parent=main_club
|
|
||||||
)
|
|
||||||
refound = Club.objects.create(
|
|
||||||
name="Carte AE", address="Jamais imprimée", parent=main_club
|
|
||||||
)
|
|
||||||
|
|
||||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=comunity,
|
user=comunity,
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
start_date=localdate(),
|
start_date=localdate(),
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=sli,
|
user=sli,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=9,
|
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
||||||
description="Padawan Troll",
|
description="Padawan Troll",
|
||||||
start_date=localdate() - timedelta(days=17),
|
start_date=localdate() - timedelta(days=17),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=krophil,
|
user=krophil,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=10,
|
role=clubs.troll.roles.get(name="Président⸱e"),
|
||||||
description="Maitre Troll",
|
description="Maitre Troll",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=skia,
|
user=skia,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=2,
|
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||||
description="Grand Ancien Troll",
|
description="Grand Ancien Troll",
|
||||||
start_date=localdate() - timedelta(days=400),
|
start_date=localdate() - timedelta(days=400),
|
||||||
end_date=localdate() - timedelta(days=86),
|
end_date=localdate() - timedelta(days=86),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=richard,
|
user=richard,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=2,
|
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||||
description="",
|
description="",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
end_date=localdate() - timedelta(days=100),
|
end_date=localdate() - timedelta(days=100),
|
||||||
)
|
)
|
||||||
|
|
||||||
p = ProductType.objects.create(name="Bières bouteilles")
|
self._create_products(groups, clubs)
|
||||||
c = ProductType.objects.create(name="Cotisations")
|
|
||||||
r = ProductType.objects.create(name="Rechargements")
|
|
||||||
verre = ProductType.objects.create(name="Verre")
|
|
||||||
cotis = Product.objects.create(
|
|
||||||
name="Cotis 1 semestre",
|
|
||||||
code="1SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cotis2 = Product.objects.create(
|
|
||||||
name="Cotis 2 semestres",
|
|
||||||
code="2SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="28",
|
|
||||||
selling_price="28",
|
|
||||||
special_selling_price="28",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
refill = Product.objects.create(
|
|
||||||
name="Rechargement 15 €",
|
|
||||||
code="15REFILL",
|
|
||||||
product_type=r,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
barb = Product.objects.create(
|
|
||||||
name="Barbar",
|
|
||||||
code="BARB",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cble = Product.objects.create(
|
|
||||||
name="Chimay Bleue",
|
|
||||||
code="CBLE",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cons = Product.objects.create(
|
|
||||||
name="Consigne Eco-cup",
|
|
||||||
code="CONS",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="1",
|
|
||||||
selling_price="1",
|
|
||||||
special_selling_price="1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
dcons = Product.objects.create(
|
|
||||||
name="Déconsigne Eco-cup",
|
|
||||||
code="DECO",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="-1",
|
|
||||||
selling_price="-1",
|
|
||||||
special_selling_price="-1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cors = Product.objects.create(
|
|
||||||
name="Corsendonk",
|
|
||||||
code="CORS",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
carolus = Product.objects.create(
|
|
||||||
name="Carolus",
|
|
||||||
code="CARO",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
Product.objects.create(
|
|
||||||
name="remboursement",
|
|
||||||
code="REMBOURS",
|
|
||||||
purchase_price="0",
|
|
||||||
selling_price="0",
|
|
||||||
special_selling_price="0",
|
|
||||||
club=refound,
|
|
||||||
)
|
|
||||||
groups.subscribers.products.add(
|
|
||||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
|
||||||
)
|
|
||||||
groups.old_subscribers.products.add(cotis, cotis2)
|
|
||||||
|
|
||||||
mde = Counter.objects.get(name="MDE")
|
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||||
mde.products.add(barb, cble, cons, dcons)
|
|
||||||
|
|
||||||
eboutic = Counter.objects.get(name="Eboutic")
|
|
||||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
|
||||||
|
|
||||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
|
||||||
|
|
||||||
ReturnableProduct.objects.create(
|
|
||||||
product=cons, returned_product=dcons, max_return=3
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add barman to counter
|
# Add barman to counter
|
||||||
Counter.sellers.through.objects.bulk_create(
|
Counter.sellers.through.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Counter.sellers.through(counter_id=2, user=krophil),
|
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
||||||
Counter.sellers.through(counter=mde, user=skia),
|
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -515,7 +381,7 @@ class Command(BaseCommand):
|
|||||||
end_date="7942-06-12 10:28:45+01",
|
end_date="7942-06-12 10:28:45+01",
|
||||||
)
|
)
|
||||||
el.view_groups.add(groups.public)
|
el.view_groups.add(groups.public)
|
||||||
el.edit_groups.add(main_club.board_group)
|
el.edit_groups.add(clubs.ae.board_group)
|
||||||
el.candidature_groups.add(groups.subscribers)
|
el.candidature_groups.add(groups.subscribers)
|
||||||
el.vote_groups.add(groups.subscribers)
|
el.vote_groups.add(groups.subscribers)
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
@@ -588,7 +454,7 @@ class Command(BaseCommand):
|
|||||||
title="Apero barman",
|
title="Apero barman",
|
||||||
summary="Viens boire un coup avec les barmans",
|
summary="Viens boire un coup avec les barmans",
|
||||||
content="Glou glou glou glou glou glou glou",
|
content="Glou glou glou glou glou glou glou",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -606,7 +472,7 @@ class Command(BaseCommand):
|
|||||||
content=(
|
content=(
|
||||||
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
||||||
),
|
),
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -622,7 +488,7 @@ class Command(BaseCommand):
|
|||||||
title="Repas fromager",
|
title="Repas fromager",
|
||||||
summary="Wien manger du l'bon fromeug'",
|
summary="Wien manger du l'bon fromeug'",
|
||||||
content="Fô viendre mangey d'la bonne fondue!",
|
content="Fô viendre mangey d'la bonne fondue!",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -638,7 +504,7 @@ class Command(BaseCommand):
|
|||||||
title="SdF",
|
title="SdF",
|
||||||
summary="Enjoy la fin des finaux!",
|
summary="Enjoy la fin des finaux!",
|
||||||
content="Viens faire la fête avec tout plein de gens!",
|
content="Viens faire la fête avec tout plein de gens!",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -656,7 +522,7 @@ class Command(BaseCommand):
|
|||||||
summary="Viens jouer!",
|
summary="Viens jouer!",
|
||||||
content="Rejoins la fine équipe du Troll Penché et viens "
|
content="Rejoins la fine équipe du Troll Penché et viens "
|
||||||
"t'amuser le Vendredi soir!",
|
"t'amuser le Vendredi soir!",
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -734,8 +600,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
pict.file.name = p.name
|
pict.file.name = p.name
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.generate_thumbnails()
|
pict.generate_thumbnails(save=True)
|
||||||
pict.save()
|
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
@@ -757,6 +622,129 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
|
||||||
|
beers_type, cotis_type, refill_type, verre_type = (
|
||||||
|
ProductType.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ProductType(name="Bières bouteilles"),
|
||||||
|
ProductType(name="Cotisations"),
|
||||||
|
ProductType(name="Rechargements"),
|
||||||
|
ProductType(name="Verre"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cotis = Product.objects.create(
|
||||||
|
name="Cotis 1 semestre",
|
||||||
|
code="1SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
cotis2 = Product.objects.create(
|
||||||
|
name="Cotis 2 semestres",
|
||||||
|
code="2SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price="28",
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
refill = Product.objects.create(
|
||||||
|
name="Rechargement 15 €",
|
||||||
|
code="15REFILL",
|
||||||
|
product_type=refill_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
barb = Product.objects.create(
|
||||||
|
name="Barbar",
|
||||||
|
code="BARB",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cble = Product.objects.create(
|
||||||
|
name="Chimay Bleue",
|
||||||
|
code="CBLE",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cons = Product.objects.create(
|
||||||
|
name="Consigne Eco-cup",
|
||||||
|
code="CONS",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="1",
|
||||||
|
club=clubs.pdf,
|
||||||
|
)
|
||||||
|
dcons = Product.objects.create(
|
||||||
|
name="Déconsigne Eco-cup",
|
||||||
|
code="DECO",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="-1",
|
||||||
|
club=clubs.pdf,
|
||||||
|
)
|
||||||
|
cors = Product.objects.create(
|
||||||
|
name="Corsendonk",
|
||||||
|
code="CORS",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
carolus = Product.objects.create(
|
||||||
|
name="Carolus",
|
||||||
|
code="CARO",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
Product.objects.create(
|
||||||
|
name="remboursement",
|
||||||
|
code="REMBOURS",
|
||||||
|
purchase_price=0,
|
||||||
|
club=clubs.refound,
|
||||||
|
)
|
||||||
|
ReturnableProduct.objects.create(
|
||||||
|
product=cons, returned_product=dcons, max_return=3
|
||||||
|
)
|
||||||
|
mde = Counter.objects.get(name="MDE")
|
||||||
|
mde.products.add(barb, cble, cons, dcons)
|
||||||
|
eboutic = Counter.objects.get(name="Eboutic")
|
||||||
|
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||||
|
|
||||||
|
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
|
||||||
|
Price.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price(product=cotis, amount=15),
|
||||||
|
Price(product=cotis2, amount=28),
|
||||||
|
Price(product=refill, amount=15),
|
||||||
|
Price(product=barb, amount=1.7),
|
||||||
|
Price(product=cble, amount=1.7),
|
||||||
|
Price(product=cors, amount=1.7),
|
||||||
|
Price(product=carolus, amount=1.7),
|
||||||
|
Price(product=cons, amount=1),
|
||||||
|
Price(product=dcons, amount=-1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Price.groups.through.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price.groups.through(price=cotis, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=refill, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=barb, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cble, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cors, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=carolus, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cons, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=dcons, group=groups.old_subscribers),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _create_profile_pict(self, user: User):
|
def _create_profile_pict(self, user: User):
|
||||||
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
||||||
file = resize_image(Image.open(path), 400, "WEBP")
|
file = resize_image(Image.open(path), 400, "WEBP")
|
||||||
@@ -793,6 +781,57 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
|
def _create_clubs(self) -> PopulatedClubs:
|
||||||
|
ae = Club.objects.create(
|
||||||
|
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||||
|
)
|
||||||
|
ae.board_group.permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=[
|
||||||
|
"view_subscription",
|
||||||
|
"add_subscription",
|
||||||
|
"add_membership",
|
||||||
|
"view_hidden_user",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pdf = Club.objects.create(
|
||||||
|
id=settings.SITH_PDF_CLUB_ID,
|
||||||
|
name="PdF",
|
||||||
|
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||||
|
)
|
||||||
|
troll = Club.objects.create(
|
||||||
|
name="Troll Penché", address="Terre Du Milieu", parent=ae
|
||||||
|
)
|
||||||
|
refound = Club.objects.create(
|
||||||
|
name="Carte AE", address="Jamais imprimée", parent=ae
|
||||||
|
)
|
||||||
|
roles = []
|
||||||
|
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
|
||||||
|
board_roles = [
|
||||||
|
"Trésorier⸱e",
|
||||||
|
"Secrétaire",
|
||||||
|
"Respo Info",
|
||||||
|
"Respo Com",
|
||||||
|
"Membre du bureau",
|
||||||
|
]
|
||||||
|
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
|
||||||
|
for club in ae, pdf, troll, refound:
|
||||||
|
for i, role in enumerate(presidency_roles):
|
||||||
|
roles.append(
|
||||||
|
ClubRole(
|
||||||
|
club=club, order=i, name=role, is_presidency=True, is_board=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for i, role in enumerate(board_roles, start=len(presidency_roles)):
|
||||||
|
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
|
||||||
|
for i, role in enumerate(
|
||||||
|
simple_roles, start=len(presidency_roles) + len(board_roles)
|
||||||
|
):
|
||||||
|
roles.append(ClubRole(club=club, order=i, name=role))
|
||||||
|
ClubRole.objects.bulk_create(roles)
|
||||||
|
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
|
||||||
|
|
||||||
def _create_groups(self) -> PopulatedGroups:
|
def _create_groups(self) -> PopulatedGroups:
|
||||||
perms = Permission.objects.all()
|
perms = Permission.objects.all()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import math
|
|
||||||
import random
|
import random
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
@@ -12,12 +11,13 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
|||||||
from django.utils.timezone import localdate, make_aware, now
|
from django.utils.timezone import localdate, make_aware, now
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.models import Group, User, UserBan
|
from core.models import Group, User, UserBan
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -35,17 +35,12 @@ class Command(BaseCommand):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.faker = Faker("fr_FR")
|
self.faker = Faker("fr_FR")
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"-n", "--nb-users", help="Number of users to create", type=int, default=600
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
raise Exception("Never call this command in prod. Never.")
|
raise Exception("Never call this command in prod. Never.")
|
||||||
|
|
||||||
self.stdout.write("Creating users...")
|
self.stdout.write("Creating users...")
|
||||||
users = self.create_users(options["nb_users"])
|
users = self.create_users()
|
||||||
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||||
self.stdout.write("Creating subscriptions...")
|
self.stdout.write("Creating subscriptions...")
|
||||||
@@ -85,7 +80,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("Creating products...")
|
self.stdout.write("Creating products...")
|
||||||
self.create_products()
|
self.create_products()
|
||||||
self.stdout.write("Creating sales and refills...")
|
self.stdout.write("Creating sales and refills...")
|
||||||
sellers = random.sample(users, len(users) // 10)
|
sellers = random.sample(list(User.objects.all()), 100)
|
||||||
self.create_sales(sellers)
|
self.create_sales(sellers)
|
||||||
self.stdout.write("Creating permanences...")
|
self.stdout.write("Creating permanences...")
|
||||||
self.create_permanences(sellers)
|
self.create_permanences(sellers)
|
||||||
@@ -94,7 +89,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write("Done")
|
self.stdout.write("Done")
|
||||||
|
|
||||||
def create_users(self, nb_users: int = 600) -> list[User]:
|
def create_users(self) -> list[User]:
|
||||||
# Create a single password hash for all users to make it faster.
|
# Create a single password hash for all users to make it faster.
|
||||||
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||||
password = make_password("plop")
|
password = make_password("plop")
|
||||||
@@ -113,7 +108,7 @@ class Command(BaseCommand):
|
|||||||
address=self.faker.address(),
|
address=self.faker.address(),
|
||||||
password=password,
|
password=password,
|
||||||
)
|
)
|
||||||
for _ in range(nb_users)
|
for _ in range(600)
|
||||||
]
|
]
|
||||||
# there may a duplicate or two
|
# there may a duplicate or two
|
||||||
# Not a problem, we will just have 599 users instead of 600
|
# Not a problem, we will just have 599 users instead of 600
|
||||||
@@ -178,20 +173,25 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||||
|
|
||||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
roles: list[ClubRole] = list(club.roles.all())
|
||||||
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
|
||||||
|
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
|
||||||
|
important_roles = [r for r in roles if r.is_board]
|
||||||
|
important_roles.sort(key=lambda r: r.order)
|
||||||
|
simple_board_role = important_roles.pop()
|
||||||
|
member_roles = [r for r in roles if not r.is_board]
|
||||||
user_idx = 0
|
user_idx = 0
|
||||||
while (role := next(roles)) > 2:
|
for _role in important_roles:
|
||||||
# one member for each major role
|
# one member for each major role
|
||||||
yield users[user_idx], role
|
yield users[user_idx], _role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||||
# 30% of the remaining in the board
|
# 30% of the remaining in the board
|
||||||
yield users[user_idx], 2
|
yield users[user_idx], simple_board_role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for remaining in users[user_idx + 1 :]:
|
for remaining in users[user_idx + 1 :]:
|
||||||
# everything else is a simple member
|
# everything else is a simple member
|
||||||
yield remaining, 1
|
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
||||||
|
|
||||||
memberships = []
|
memberships = []
|
||||||
old_members = old_members.copy()
|
old_members = old_members.copy()
|
||||||
@@ -203,19 +203,14 @@ class Command(BaseCommand):
|
|||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=self.faker.past_date(start),
|
end_date=self.faker.past_date(start),
|
||||||
user=old,
|
user=old,
|
||||||
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
role=random.choice(roles),
|
||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for member, role in zip_roles(members):
|
for member, role in zip_roles(members):
|
||||||
start = self.faker.past_date("-1y")
|
start = self.faker.past_date("-1y")
|
||||||
memberships.append(
|
memberships.append(
|
||||||
Membership(
|
Membership(start_date=start, user=member, role=role, club=club)
|
||||||
start_date=start,
|
|
||||||
user=member,
|
|
||||||
role=role,
|
|
||||||
club=club,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
memberships = Membership.objects.bulk_create(memberships)
|
memberships = Membership.objects.bulk_create(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
@@ -284,6 +279,7 @@ class Command(BaseCommand):
|
|||||||
# 2/3 of the products are owned by AE
|
# 2/3 of the products are owned by AE
|
||||||
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
||||||
products = []
|
products = []
|
||||||
|
prices = []
|
||||||
buying_groups = []
|
buying_groups = []
|
||||||
selling_places = []
|
selling_places = []
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
@@ -294,25 +290,28 @@ class Command(BaseCommand):
|
|||||||
product_type=random.choice(categories),
|
product_type=random.choice(categories),
|
||||||
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
||||||
purchase_price=price,
|
purchase_price=price,
|
||||||
selling_price=price,
|
|
||||||
special_selling_price=price - min(0.5, price),
|
|
||||||
club=random.choice(clubs),
|
club=random.choice(clubs),
|
||||||
limit_age=0 if random.random() > 0.2 else 18,
|
limit_age=0 if random.random() > 0.2 else 18,
|
||||||
archived=bool(random.random() > 0.7),
|
archived=self.faker.boolean(60),
|
||||||
)
|
)
|
||||||
products.append(product)
|
products.append(product)
|
||||||
# there will be products without buying groups
|
for i in range(random.randint(0, 3)):
|
||||||
# but there are also such products in the real database
|
product_price = Price(
|
||||||
buying_groups.extend(
|
amount=price, product=product, is_always_shown=self.faker.boolean()
|
||||||
Product.buying_groups.through(product=product, group=group)
|
)
|
||||||
for group in random.sample(groups, k=random.randint(0, 3))
|
# prices for non-subscribers will be higher than for subscribers
|
||||||
|
price *= 1.2
|
||||||
|
prices.append(product_price)
|
||||||
|
buying_groups.append(
|
||||||
|
Price.groups.through(price=product_price, group=groups[i])
|
||||||
)
|
)
|
||||||
selling_places.extend(
|
selling_places.extend(
|
||||||
Counter.products.through(counter=counter, product=product)
|
Counter.products.through(counter=counter, product=product)
|
||||||
for counter in random.sample(counters, random.randint(0, 4))
|
for counter in random.sample(counters, random.randint(0, 4))
|
||||||
)
|
)
|
||||||
Product.objects.bulk_create(products)
|
Product.objects.bulk_create(products)
|
||||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
Price.objects.bulk_create(prices)
|
||||||
|
Price.groups.through.objects.bulk_create(buying_groups)
|
||||||
Counter.products.through.objects.bulk_create(selling_places)
|
Counter.products.through.objects.bulk_create(selling_places)
|
||||||
|
|
||||||
def create_sales(self, sellers: list[User]):
|
def create_sales(self, sellers: list[User]):
|
||||||
@@ -326,7 +325,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
products = list(Product.objects.all())
|
prices = list(Price.objects.select_related("product").all())
|
||||||
counters = list(
|
counters = list(
|
||||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
||||||
)
|
)
|
||||||
@@ -336,14 +335,14 @@ class Command(BaseCommand):
|
|||||||
# the longer the customer has existed, the higher the mean of nb_products
|
# the longer the customer has existed, the higher the mean of nb_products
|
||||||
mu = 5 + (now().year - customer.since.year) * 2
|
mu = 5 + (now().year - customer.since.year) * 2
|
||||||
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
||||||
favoured_products = random.sample(products, k=(random.randint(1, 5)))
|
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
|
||||||
favoured_counter = random.choice(counters)
|
favoured_counter = random.choice(counters)
|
||||||
this_customer_sales = []
|
this_customer_sales = []
|
||||||
for _ in range(nb_sales):
|
for _ in range(nb_sales):
|
||||||
product = (
|
price = (
|
||||||
random.choice(favoured_products)
|
random.choice(favoured_prices)
|
||||||
if random.random() > 0.7
|
if random.random() > 0.7
|
||||||
else random.choice(products)
|
else random.choice(prices)
|
||||||
)
|
)
|
||||||
counter = (
|
counter = (
|
||||||
favoured_counter
|
favoured_counter
|
||||||
@@ -352,11 +351,11 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
this_customer_sales.append(
|
this_customer_sales.append(
|
||||||
Selling(
|
Selling(
|
||||||
product=product,
|
product=price.product,
|
||||||
counter=counter,
|
counter=counter,
|
||||||
club_id=product.club_id,
|
club_id=price.product.club_id,
|
||||||
quantity=random.randint(1, 5),
|
quantity=random.randint(1, 5),
|
||||||
unit_price=product.selling_price,
|
unit_price=price.amount,
|
||||||
seller=random.choice(sellers),
|
seller=random.choice(sellers),
|
||||||
customer=customer,
|
customer=customer,
|
||||||
date=make_aware(
|
date=make_aware(
|
||||||
@@ -416,9 +415,8 @@ class Command(BaseCommand):
|
|||||||
Permanency.objects.bulk_create(perms)
|
Permanency.objects.bulk_create(perms)
|
||||||
|
|
||||||
def create_forums(self):
|
def create_forums(self):
|
||||||
users = list(User.objects.all())
|
forumers = random.sample(list(User.objects.all()), 100)
|
||||||
forumers = random.sample(users, math.ceil(len(users) / 10))
|
most_actives = random.sample(forumers, 10)
|
||||||
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
|
|
||||||
categories = list(Forum.objects.filter(is_category=True))
|
categories = list(Forum.objects.filter(is_category=True))
|
||||||
new_forums = [
|
new_forums = [
|
||||||
Forum(name=self.faker.text(20), parent=random.choice(categories))
|
Forum(name=self.faker.text(20), parent=random.choice(categories))
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-01 08:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
|
||||||
|
def set_updated_at(apps: StateApps, schema_editor):
|
||||||
|
SithFile = apps.get_model("core", "SithFile")
|
||||||
|
SithFile.objects.update(updated_at=F("date"))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0049_user_whitelisted_users")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="moderator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="moderated_files",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="owned_files",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="updated_at",
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
+3
-2
@@ -853,7 +853,7 @@ class SithFile(models.Model):
|
|||||||
User,
|
User,
|
||||||
related_name="owned_files",
|
related_name="owned_files",
|
||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
edit_groups = models.ManyToManyField(
|
edit_groups = models.ManyToManyField(
|
||||||
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
||||||
@@ -865,6 +865,7 @@ class SithFile(models.Model):
|
|||||||
mime_type = models.CharField(_("mime type"), max_length=30)
|
mime_type = models.CharField(_("mime type"), max_length=30)
|
||||||
size = models.IntegerField(_("size"), default=0)
|
size = models.IntegerField(_("size"), default=0)
|
||||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||||
moderator = models.ForeignKey(
|
moderator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@@ -872,7 +873,7 @@ class SithFile(models.Model):
|
|||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||||
is_in_sas = models.BooleanField(
|
is_in_sas = models.BooleanField(
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import sort from "@alpinejs/sort";
|
|
||||||
import Alpine from "alpinejs";
|
|
||||||
import { limitedChoices } from "#core:alpine/limited-choices.ts";
|
|
||||||
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
|
|
||||||
|
|
||||||
Alpine.plugin([sort, limitedChoices]);
|
|
||||||
Alpine.magic("notifications", notificationPlugin);
|
|
||||||
window.Alpine = Alpine;
|
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
Alpine.start();
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import sort from "@alpinejs/sort";
|
||||||
|
import { Alpine } from "alpinejs";
|
||||||
|
import { limitedChoices } from "#core:alpine/limited-choices";
|
||||||
|
import {
|
||||||
|
type NotificationPlugin,
|
||||||
|
alpinePlugin as notificationPlugin,
|
||||||
|
} from "#core:utils/notifications";
|
||||||
|
|
||||||
|
declare module "alpinejs" {
|
||||||
|
interface Magics<T> {
|
||||||
|
$notifications: NotificationPlugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Alpine.plugin([sort, limitedChoices]);
|
||||||
|
Alpine.magic("notifications", notificationPlugin);
|
||||||
|
// biome-ignore lint/style/useNamingConvention: it's how it's named
|
||||||
|
Object.assign(window, { Alpine });
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
Alpine.start();
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
?aria-selected=${this.active}
|
?aria-selected="${this.active}"
|
||||||
class="tab-header clickable ${this.active ? "active" : ""}"
|
class="tab-header clickable ${this.active ? "active" : ""}"
|
||||||
@click="${() => this.setActive(true)}"
|
@click="${() => this.setActive(true)}"
|
||||||
>
|
>
|
||||||
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<section
|
<section
|
||||||
class="tab-section"
|
class="tab-section"
|
||||||
?hidden=${!this.active}
|
?hidden="${!this.active}"
|
||||||
>
|
>
|
||||||
${unsafeHTML(this.getContentHtml())}
|
${unsafeHTML(this.getContentHtml())}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
|
import { Alpine } from "alpinejs";
|
||||||
|
|
||||||
export enum NotificationLevel {
|
export enum NotificationLevel {
|
||||||
Error = "error",
|
Error = "error",
|
||||||
Warning = "warning",
|
Warning = "warning",
|
||||||
Success = "success",
|
Success = "success",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNotification(message: string, level: NotificationLevel) {
|
export interface NotificationPlugin {
|
||||||
const element = document.getElementById("quick-notifications");
|
error: (message: string) => void;
|
||||||
if (element === null) {
|
warning: (message: string) => void;
|
||||||
return false;
|
success: (message: string) => void;
|
||||||
}
|
clear: () => void;
|
||||||
return element.dispatchEvent(
|
addMany: (notifs: Notification[]) => void;
|
||||||
new CustomEvent("quick-notification-add", {
|
getAll: () => Notification[];
|
||||||
detail: { text: message, tag: level },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteNotifications() {
|
export interface Notification {
|
||||||
const element = document.getElementById("quick-notifications");
|
tag: NotificationLevel;
|
||||||
if (element === null) {
|
text: string;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function alpinePlugin() {
|
Alpine.store("notifications", [] as Notification[]);
|
||||||
|
|
||||||
|
function createNotification(message: string, level: NotificationLevel) {
|
||||||
|
(Alpine.store("notifications") as Notification[]).push({ text: message, tag: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNotifications() {
|
||||||
|
Alpine.store("notifications", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotifications() {
|
||||||
|
return Alpine.store("notifications") as Notification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alpinePlugin(): NotificationPlugin {
|
||||||
return {
|
return {
|
||||||
error: (message: string) => createNotification(message, NotificationLevel.Error),
|
error: (message: string) => createNotification(message, NotificationLevel.Error),
|
||||||
warning: (message: string) =>
|
warning: (message: string) =>
|
||||||
@@ -32,5 +42,10 @@ export function alpinePlugin() {
|
|||||||
success: (message: string) =>
|
success: (message: string) =>
|
||||||
createNotification(message, NotificationLevel.Success),
|
createNotification(message, NotificationLevel.Success),
|
||||||
clear: () => deleteNotifications(),
|
clear: () => deleteNotifications(),
|
||||||
|
addMany: (notifs: Notification[]) =>
|
||||||
|
notifs.forEach((n) => {
|
||||||
|
createNotification(n.text, n.tag);
|
||||||
|
}),
|
||||||
|
getAll: () => getNotifications(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,18 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
implements InheritedHtmlElement<K>
|
implements InheritedHtmlElement<K>
|
||||||
{
|
{
|
||||||
readonly inheritedTagName = tagName;
|
readonly inheritedTagName = tagName;
|
||||||
|
private readonly initializedAttribute = "component-initialized";
|
||||||
node: HTMLElementTagNameMap[K];
|
node: HTMLElementTagNameMap[K];
|
||||||
|
|
||||||
connectedCallback(autoAddNode?: boolean) {
|
connectedCallback(autoAddNode?: boolean) {
|
||||||
|
// When nesting inherited elements, we might trigger the wrapping twice
|
||||||
|
// To avoid this, we tag a created element as initialized
|
||||||
|
// We then skip the initialization step and grab the inner content as the node
|
||||||
|
if (this.hasAttribute(this.initializedAttribute)) {
|
||||||
|
this.node = this.firstChild as HTMLElementTagNameMap[K];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.node = document.createElement(this.inheritedTagName);
|
this.node = document.createElement(this.inheritedTagName);
|
||||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||||
for (const attr of this.attributes) {
|
for (const attr of this.attributes) {
|
||||||
@@ -58,6 +67,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setAttribute(this.initializedAttribute, "");
|
||||||
|
|
||||||
// We move compatible attributes to the child element
|
// We move compatible attributes to the child element
|
||||||
// This avoids weird inconsistencies between attributes
|
// This avoids weird inconsistencies between attributes
|
||||||
// when we manipulate the dom in the future
|
// when we manipulate the dom in the future
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
@supports (max-height: calc-size(max-content, size)) {
|
@supports (max-height: calc-size(max-content, size)) {
|
||||||
max-height: 0px;
|
max-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +71,12 @@ details.accordion>.accordion-content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ::details-content isn't available on firefox yet
|
// ::details-content is available on firefox only since september 2025
|
||||||
|
// (and wasn't available when this code was initially written)
|
||||||
// we use .accordion-content as a workaround
|
// we use .accordion-content as a workaround
|
||||||
// But we need to use ::details-content for chrome because it's
|
// But we need to use ::details-content for chrome because it's
|
||||||
// not working correctly otherwise
|
// not working correctly otherwise
|
||||||
// it only happen in chrome, not safari or firefox
|
// it only happens in chrome, not safari or firefox
|
||||||
// Note: `selector` is not supported by scss so we comment it out to
|
// Note: `selector` is not supported by scss so we comment it out to
|
||||||
// avoid compiling it and sending it straight to the css
|
// avoid compiling it and sending it straight to the css
|
||||||
// This is a trick that comes from here :
|
// This is a trick that comes from here :
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ form {
|
|||||||
display: block;
|
display: block;
|
||||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
.fields-centered {
|
.fields-centered {
|
||||||
padding: 10px 10px 0;
|
padding: 10px 10px 0;
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ body {
|
|||||||
|
|
||||||
/*--------------------------------CONTENT------------------------------*/
|
/*--------------------------------CONTENT------------------------------*/
|
||||||
#content {
|
#content {
|
||||||
padding: 1em 1%;
|
padding: 1.5em 3%;
|
||||||
box-shadow: $shadow-color 0 5px 10px;
|
box-shadow: $shadow-color 0 5px 10px;
|
||||||
background: $white-color;
|
background: $white-color;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<script src="{{ url('javascript-catalog') }}"></script>
|
<script src="{{ url('javascript-catalog') }}"></script>
|
||||||
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
|
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
|
||||||
<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.ts') }}"></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>
|
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<div id="quick-notifications"
|
<div id="quick-notifications"
|
||||||
x-data="{
|
x-data='{ messages: $notifications.getAll() }'
|
||||||
messages: [
|
x-init='$notifications.addMany([
|
||||||
{%- for message in messages -%}
|
{%- for message in messages -%}
|
||||||
{%- if not message.extra_tags -%}
|
{%- if not message.extra_tags -%}
|
||||||
{ tag: '{{ message.tags }}', text: '{{ message }}' },
|
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
]
|
])'
|
||||||
}"
|
>
|
||||||
@quick-notification-add="(e) => messages.push(e?.detail)"
|
|
||||||
@quick-notification-delete="messages = []">
|
|
||||||
<template x-for="(message, index) in messages">
|
<template x-for="(message, index) in messages">
|
||||||
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
||||||
<span class="alert-main" x-text="message.text"></span>
|
<span class="alert-main" x-text="message.text"></span>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{# if the template context has the `object_name` variable,
|
||||||
|
then this one will be used in the page title,
|
||||||
|
instead of the result of `str(object)` #}
|
||||||
|
{% if not object_name %}
|
||||||
|
{% set object_name=form.instance.__class__._meta.verbose_name %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
|
{% trans name=object_name %}Create {{ name }}{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
|
||||||
<form action="" method="post" enctype="multipart/form-data">
|
<form action="" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form.as_p() }}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
||||||
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
||||||
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
||||||
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
{% trans %}Date: {% endtrans %}
|
||||||
|
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
||||||
</p>
|
</p>
|
||||||
<p><button
|
<p><button
|
||||||
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<span class="helptext">{{ form.is_viewable.help_text }}</span>
|
<span class="helptext">{{ form.is_viewable.help_text }}</span>
|
||||||
{{ form.is_viewable.errors }}
|
{{ form.is_viewable.errors }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="form-group" x-show="!isViewable">
|
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak>
|
||||||
{{ form.whitelisted_users.as_field_group() }}
|
{{ form.whitelisted_users.as_field_group() }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="form-group">
|
<fieldset class="form-group">
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.filter(end_date=None).all() %}
|
{% for m in profile.memberships.ongoing().select_related("role") %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
<td>{{ m.role.name }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{% if m.can_be_edited_by(user) %}
|
{% if m.can_be_edited_by(user) %}
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.exclude(end_date=None).all() %}
|
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
<td>{{ m.role.name }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
<td>{{ m.end_date }}</td>
|
<td>{{ m.end_date }}</td>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<script-once type="module" src="{{ js }}"></script-once>
|
<script-once type="module" src="{{ js }}"></script-once>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for css in statics.css %}
|
{% for css in statics.css %}
|
||||||
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
|
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import contextlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.core.management import call_command
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_populate_more(settings):
|
|
||||||
"""Just check that populate more doesn't crash"""
|
|
||||||
settings.DEBUG = True
|
|
||||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
|
||||||
call_command("populate_more", "--nb-users", "50")
|
|
||||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Page, PageRev, User
|
from core.models import AnonymousUser, Page, PageRev, User
|
||||||
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_viewable_by():
|
def test_viewable_by():
|
||||||
# remove existing pages to prevent side effect
|
# remove existing pages to prevent side effect
|
||||||
|
# club pages are protected, so we must delete clubs first
|
||||||
|
Membership.objects.all().delete()
|
||||||
|
Club.objects.all().delete()
|
||||||
Page.objects.all().delete()
|
Page.objects.all().delete()
|
||||||
view_groups = [
|
view_groups = [
|
||||||
[settings.SITH_GROUP_PUBLIC_ID],
|
[settings.SITH_GROUP_PUBLIC_ID],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from core.baker_recipes import (
|
|||||||
subscriber_user,
|
subscriber_user,
|
||||||
very_old_subscriber_user,
|
very_old_subscriber_user,
|
||||||
)
|
)
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, Group, SithFile, User
|
||||||
from core.views import UserTabsMixin
|
from core.views import UserTabsMixin
|
||||||
from counter.baker_recipes import sale_recipe
|
from counter.baker_recipes import sale_recipe
|
||||||
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
||||||
@@ -34,6 +34,7 @@ class TestSearchUsers(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
|
SithFile.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
user_recipe = Recipe(
|
user_recipe = Recipe(
|
||||||
User,
|
User,
|
||||||
@@ -213,9 +214,9 @@ def test_user_invoice_with_multiple_items():
|
|||||||
"""Test that annotate_total() works when invoices contain multiple items."""
|
"""Test that annotate_total() works when invoices contain multiple items."""
|
||||||
user: User = subscriber_user.make()
|
user: User = subscriber_user.make()
|
||||||
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
||||||
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
|
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
|
||||||
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
|
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
|
||||||
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
|
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
|
||||||
res = list(
|
res = list(
|
||||||
Invoice.objects.filter(user=user)
|
Invoice.objects.filter(user=user)
|
||||||
.annotate_total()
|
.annotate_total()
|
||||||
|
|||||||
+3
-57
@@ -12,31 +12,20 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hmac
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
# Image utils
|
# Image utils
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import TYPE_CHECKING
|
from typing import Final
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.utils.timezone import localdate
|
|
||||||
from PIL import ExifTags
|
|
||||||
from PIL.Image import Image, Resampling
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from _hashlib import HASH
|
|
||||||
from collections.abc import Buffer, Mapping, Sequence
|
|
||||||
from typing import Any, Callable, Final
|
|
||||||
|
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
RED_PIXEL_PNG: Final[bytes] = (
|
RED_PIXEL_PNG: Final[bytes] = (
|
||||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||||
@@ -188,22 +177,6 @@ def resize_image_explicit(
|
|||||||
return ContentFile(content.getvalue())
|
return ContentFile(content.getvalue())
|
||||||
|
|
||||||
|
|
||||||
def exif_auto_rotate(image):
|
|
||||||
for orientation in ExifTags.TAGS:
|
|
||||||
if ExifTags.TAGS[orientation] == "Orientation":
|
|
||||||
break
|
|
||||||
exif = dict(image._getexif().items())
|
|
||||||
|
|
||||||
if exif[orientation] == 3:
|
|
||||||
image = image.rotate(180, expand=True)
|
|
||||||
elif exif[orientation] == 6:
|
|
||||||
image = image.rotate(270, expand=True)
|
|
||||||
elif exif[orientation] == 8:
|
|
||||||
image = image.rotate(90, expand=True)
|
|
||||||
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: HttpRequest) -> str | None:
|
def get_client_ip(request: HttpRequest) -> str | None:
|
||||||
headers = (
|
headers = (
|
||||||
"X_FORWARDED_FOR", # Common header for proxies
|
"X_FORWARDED_FOR", # Common header for proxies
|
||||||
@@ -215,30 +188,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def hmac_hexdigest(
|
|
||||||
key: str | bytes,
|
|
||||||
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
|
|
||||||
digest: str | Callable[[Buffer], HASH] = "sha512",
|
|
||||||
) -> str:
|
|
||||||
"""Return the hexdigest of the signature of the given data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: the HMAC key used for the signature
|
|
||||||
data: the data to sign
|
|
||||||
digest: a PEP247 hashing algorithm (by default, sha512)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```python
|
|
||||||
data = {
|
|
||||||
"foo": 5,
|
|
||||||
"bar": "somevalue",
|
|
||||||
}
|
|
||||||
hmac_key = secrets.token_hex(64)
|
|
||||||
signature = hmac_hexdigest(hmac_key, data, "sha256")
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
if isinstance(key, str):
|
|
||||||
key = key.encode()
|
|
||||||
return hmac.digest(key, urlencode(data).encode(), digest).hex()
|
|
||||||
|
|||||||
+7
-1
@@ -24,6 +24,7 @@ from counter.models import (
|
|||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -32,19 +33,24 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceInline(admin.TabularInline):
|
||||||
|
model = Price
|
||||||
|
autocomplete_fields = ("groups",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
class ProductAdmin(SearchModelAdmin):
|
class ProductAdmin(SearchModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"name",
|
"name",
|
||||||
"code",
|
"code",
|
||||||
"product_type",
|
"product_type",
|
||||||
"selling_price",
|
|
||||||
"archived",
|
"archived",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_select_related = ("product_type",)
|
list_select_related = ("product_type",)
|
||||||
search_fields = ("name", "code")
|
search_fields = ("name", "code")
|
||||||
|
inlines = [PriceInline]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ReturnableProduct)
|
@admin.register(ReturnableProduct)
|
||||||
|
|||||||
+2
-6
@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
|
|||||||
"""Get the detailed information about the products."""
|
"""Get the detailed information about the products."""
|
||||||
return filters.filter(
|
return filters.filter(
|
||||||
Product.objects.select_related("club")
|
Product.objects.select_related("club")
|
||||||
.prefetch_related("buying_groups")
|
.prefetch_related("prices", "prices__groups")
|
||||||
.select_related("product_type")
|
.select_related("product_type")
|
||||||
.order_by(
|
.order_by(F("product_type__order").asc(nulls_last=True), "name")
|
||||||
F("product_type__order").asc(nulls_last=True),
|
|
||||||
"product_type",
|
|
||||||
"name",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
|
|||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter, Product, Refilling, Selling
|
from counter.models import Counter, Price, Product, Refilling, Selling
|
||||||
|
|
||||||
counter_recipe = Recipe(Counter)
|
counter_recipe = Recipe(Counter)
|
||||||
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
||||||
|
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
|
||||||
sale_recipe = Recipe(
|
sale_recipe = Recipe(
|
||||||
Selling,
|
Selling,
|
||||||
product=foreign_key(product_recipe),
|
product=foreign_key(product_recipe),
|
||||||
|
|||||||
+51
-62
@@ -1,12 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator
|
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.forms import BaseModelFormSet
|
from django.forms import BaseModelFormSet
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -37,6 +37,7 @@ from counter.models import (
|
|||||||
Customer,
|
Customer,
|
||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -374,7 +375,21 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
|
|||||||
can_delete=True,
|
can_delete=True,
|
||||||
can_delete_extra=False,
|
can_delete_extra=False,
|
||||||
extra=0,
|
extra=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ProductPriceFormSet = forms.inlineformset_factory(
|
||||||
|
parent_model=Product,
|
||||||
|
model=Price,
|
||||||
|
fields=["amount", "label", "groups", "is_always_shown"],
|
||||||
|
widgets={
|
||||||
|
"groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
},
|
||||||
|
absolute_max=None,
|
||||||
|
can_delete_extra=False,
|
||||||
min_num=1,
|
min_num=1,
|
||||||
|
extra=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -389,14 +404,12 @@ class ProductForm(forms.ModelForm):
|
|||||||
"description",
|
"description",
|
||||||
"product_type",
|
"product_type",
|
||||||
"code",
|
"code",
|
||||||
"buying_groups",
|
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
|
||||||
"special_selling_price",
|
|
||||||
"icon",
|
"icon",
|
||||||
"club",
|
"club",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
"tray",
|
"tray",
|
||||||
|
"clic_limit",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
@@ -407,8 +420,8 @@ class ProductForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
"product_type": AutoCompleteSelect,
|
"product_type": AutoCompleteSelect,
|
||||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
|
||||||
"club": AutoCompleteSelectClub,
|
"club": AutoCompleteSelectClub,
|
||||||
|
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
counters = forms.ModelMultipleChoiceField(
|
counters = forms.ModelMultipleChoiceField(
|
||||||
@@ -418,50 +431,40 @@ class ProductForm(forms.ModelForm):
|
|||||||
queryset=Counter.objects.all(),
|
queryset=Counter.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, instance=None, **kwargs):
|
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
|
||||||
super().__init__(*args, instance=instance, **kwargs)
|
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
|
||||||
|
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
|
||||||
if self.instance.id:
|
if self.instance.id:
|
||||||
self.fields["counters"].initial = self.instance.counters.all()
|
self.fields["counters"].initial = self.instance.counters.all()
|
||||||
if hasattr(self.instance, "formula"):
|
if hasattr(self.instance, "formula"):
|
||||||
self.formula_init(self.instance.formula)
|
self.formula_init(self.instance.formula)
|
||||||
|
self.price_formset = ProductPriceFormSet(
|
||||||
|
*args, instance=self.instance, prefix="price", **kwargs
|
||||||
|
)
|
||||||
self.action_formset = ScheduledProductActionFormSet(
|
self.action_formset = ScheduledProductActionFormSet(
|
||||||
*args, product=self.instance, **kwargs
|
*args, product=self.instance, prefix="action", **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def formula_init(self, formula: ProductFormula):
|
|
||||||
"""Part of the form initialisation specific to formula products."""
|
|
||||||
self.fields["selling_price"].help_text = _(
|
|
||||||
"This product is a formula. "
|
|
||||||
"Its price cannot be greater than the price "
|
|
||||||
"of the products constituting it, which is %(price)s €"
|
|
||||||
) % {"price": formula.max_selling_price}
|
|
||||||
self.fields["special_selling_price"].help_text = _(
|
|
||||||
"This product is a formula. "
|
|
||||||
"Its special price cannot be greater than the price "
|
|
||||||
"of the products constituting it, which is %(price)s €"
|
|
||||||
) % {"price": formula.max_special_selling_price}
|
|
||||||
for key, price in (
|
|
||||||
("selling_price", formula.max_selling_price),
|
|
||||||
("special_selling_price", formula.max_special_selling_price),
|
|
||||||
):
|
|
||||||
self.fields[key].widget.attrs["max"] = price
|
|
||||||
self.fields[key].validators.append(MaxValueValidator(price))
|
|
||||||
|
|
||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
return super().is_valid() and self.action_formset.is_valid()
|
return (
|
||||||
|
super().is_valid()
|
||||||
|
and self.price_formset.is_valid()
|
||||||
|
and self.action_formset.is_valid()
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> Product:
|
def save(self, *args, **kwargs) -> Product:
|
||||||
product = super().save(*args, **kwargs)
|
product = super().save(*args, **kwargs)
|
||||||
product.counters.set(self.cleaned_data["counters"])
|
product.counters.set(self.cleaned_data["counters"])
|
||||||
for form in self.action_formset:
|
|
||||||
# if it's a creation, the product given in the formset
|
# if it's a creation, the product given in the formset
|
||||||
# wasn't a persisted instance.
|
# wasn't a persisted instance.
|
||||||
# So if we tried to persist the scheduled actions in the current state,
|
# So if we tried to persist the related objects in the current state,
|
||||||
# they would be linked to no product, thus be completely useless
|
# they would be linked to no product, thus be completely useless
|
||||||
# To make it work, we have to replace
|
# To make it work, we have to replace
|
||||||
# the initial product with a persisted one
|
# the initial product with a persisted one
|
||||||
|
for form in self.action_formset:
|
||||||
form.set_product(product)
|
form.set_product(product)
|
||||||
self.action_formset.save()
|
self.action_formset.save()
|
||||||
|
self.price_formset.save()
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
@@ -484,18 +487,6 @@ class ProductFormulaForm(forms.ModelForm):
|
|||||||
"the result and a part of the formula."
|
"the result and a part of the formula."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
prices = [p.selling_price for p in cleaned_data["products"]]
|
|
||||||
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
|
|
||||||
selling_price = cleaned_data["result"].selling_price
|
|
||||||
special_selling_price = cleaned_data["result"].special_selling_price
|
|
||||||
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
|
|
||||||
self.add_error(
|
|
||||||
"result",
|
|
||||||
_(
|
|
||||||
"The result cannot be more expensive "
|
|
||||||
"than the total of the other products."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
@@ -546,48 +537,47 @@ class CloseCustomerAccountForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BasketProductForm(forms.Form):
|
class BasketItemForm(forms.Form):
|
||||||
quantity = forms.IntegerField(min_value=1, required=True)
|
quantity = forms.IntegerField(min_value=1, required=True)
|
||||||
id = forms.IntegerField(min_value=0, required=True)
|
price_id = forms.IntegerField(min_value=0, required=True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
customer: Customer,
|
customer: Customer,
|
||||||
counter: Counter,
|
counter: Counter,
|
||||||
allowed_products: dict[int, Product],
|
allowed_prices: dict[int, Price],
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.customer = customer # Used by formset
|
self.customer = customer # Used by formset
|
||||||
self.counter = counter # Used by formset
|
self.counter = counter # Used by formset
|
||||||
self.allowed_products = allowed_products
|
self.allowed_prices = allowed_prices
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def clean_id(self):
|
def clean_price_id(self):
|
||||||
data = self.cleaned_data["id"]
|
data = self.cleaned_data["price_id"]
|
||||||
|
|
||||||
# We store self.product so we can use it later on the formset validation
|
# We store self.price so we can use it later on the formset validation
|
||||||
# And also in the global clean
|
# And also in the global clean
|
||||||
self.product = self.allowed_products.get(data, None)
|
self.price = self.allowed_prices.get(data, None)
|
||||||
if self.product is None:
|
if self.price is None:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("The selected product isn't available for this user")
|
_("The selected product isn't available for this user")
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
if len(self.errors) > 0:
|
if len(self.errors) > 0:
|
||||||
return
|
return cleaned_data
|
||||||
|
|
||||||
# Compute prices
|
# Compute prices
|
||||||
cleaned_data["bonus_quantity"] = 0
|
cleaned_data["bonus_quantity"] = 0
|
||||||
if self.product.tray:
|
if self.price.product.tray:
|
||||||
cleaned_data["bonus_quantity"] = math.floor(
|
cleaned_data["bonus_quantity"] = math.floor(
|
||||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||||
)
|
)
|
||||||
cleaned_data["total_price"] = self.product.price * (
|
cleaned_data["total_price"] = self.price.amount * (
|
||||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -611,8 +601,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||||
|
|
||||||
def _check_product_are_unique(self):
|
def _check_product_are_unique(self):
|
||||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
price_ids = {form.cleaned_data["price_id"] for form in self.forms}
|
||||||
if len(product_ids) != len(self.forms):
|
if len(price_ids) != len(self.forms):
|
||||||
raise forms.ValidationError(_("Duplicated product entries."))
|
raise forms.ValidationError(_("Duplicated product entries."))
|
||||||
|
|
||||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||||
@@ -622,10 +612,9 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
def _check_recorded_products(self, customer: Customer):
|
def _check_recorded_products(self, customer: Customer):
|
||||||
"""Check for, among other things, ecocups and pitchers"""
|
"""Check for, among other things, ecocups and pitchers"""
|
||||||
items = {
|
items = defaultdict(int)
|
||||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
for form in self.forms:
|
||||||
for form in self.forms
|
items[form.price.product_id] += form.cleaned_data["quantity"]
|
||||||
}
|
|
||||||
ids = list(items.keys())
|
ids = list(items.keys())
|
||||||
returnables = list(
|
returnables = list(
|
||||||
ReturnableProduct.objects.filter(
|
ReturnableProduct.objects.filter(
|
||||||
@@ -651,7 +640,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
|
|
||||||
BasketForm = forms.formset_factory(
|
BasketForm = forms.formset_factory(
|
||||||
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-18 13:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
|
||||||
|
import counter.fields
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_prices(apps: StateApps, schema_editor):
|
||||||
|
Product = apps.get_model("counter", "Product")
|
||||||
|
Price = apps.get_model("counter", "Price")
|
||||||
|
prices = [
|
||||||
|
Price(
|
||||||
|
amount=p.selling_price,
|
||||||
|
product=p,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
for p in Product.objects.all()
|
||||||
|
]
|
||||||
|
Price.objects.bulk_create(prices)
|
||||||
|
groups = [
|
||||||
|
Price.groups.through(price=price, group=group)
|
||||||
|
for price in Price.objects.select_related("product").prefetch_related(
|
||||||
|
"product__buying_groups"
|
||||||
|
)
|
||||||
|
for group in price.product.buying_groups.all()
|
||||||
|
]
|
||||||
|
Price.groups.through.objects.bulk_create(groups)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0048_alter_user_options"),
|
||||||
|
("counter", "0038_countersellers"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Price",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount",
|
||||||
|
counter.fields.CurrencyField(
|
||||||
|
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_always_shown",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=(
|
||||||
|
"If this option is enabled, "
|
||||||
|
"people will see this price and be able to pay it, "
|
||||||
|
"even if another cheaper price exists. "
|
||||||
|
"Else it will visible only if it is the cheapest available price."
|
||||||
|
),
|
||||||
|
verbose_name="always show",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"label",
|
||||||
|
models.CharField(
|
||||||
|
default="",
|
||||||
|
help_text=(
|
||||||
|
"A short label for easier differentiation "
|
||||||
|
"if a user can see multiple prices."
|
||||||
|
),
|
||||||
|
max_length=32,
|
||||||
|
verbose_name="label",
|
||||||
|
blank=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="prices", to="core.group", verbose_name="groups"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="prices",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "price"},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="tray",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Buy five, get the sixth free",
|
||||||
|
verbose_name="tray price",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(model_name="product", name="selling_price"),
|
||||||
|
migrations.RemoveField(model_name="product", name="special_selling_price"),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, default="", verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="product_type",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="products",
|
||||||
|
to="counter.producttype",
|
||||||
|
verbose_name="product type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="productformula",
|
||||||
|
name="result",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
help_text="The product got with the formula.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="formula",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="result product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.13 on 2026-05-13 11:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0039_price")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="product", name="buying_groups"),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="clic_limit",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"If a limit is set, the product won't be purchasable "
|
||||||
|
"anymore once the latter is reached."
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
verbose_name="clic limit",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+127
-91
@@ -34,6 +34,7 @@ from django.forms import ValidationError
|
|||||||
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.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
@@ -157,14 +158,7 @@ class Customer(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def can_buy(self) -> bool:
|
def can_buy(self) -> bool:
|
||||||
"""Check if whether this customer has the right to purchase any item.
|
"""Check if whether this customer has the right to purchase any item."""
|
||||||
|
|
||||||
This must be not confused with the Product.can_be_sold_to(user)
|
|
||||||
method as the present method returns an information
|
|
||||||
about a customer whereas the other tells something
|
|
||||||
about the relation between a User (not a Customer,
|
|
||||||
don't mix them) and a Product.
|
|
||||||
"""
|
|
||||||
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
||||||
if subscription is None:
|
if subscription is None:
|
||||||
return False
|
return False
|
||||||
@@ -357,30 +351,56 @@ class ProductType(OrderedModel):
|
|||||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuerySet(models.QuerySet):
|
||||||
|
def under_clic_limit(self) -> Self:
|
||||||
|
"""Filter product which clic limit isn't reached yet.
|
||||||
|
|
||||||
|
The clic limit is reached when the amount of sales
|
||||||
|
and of items in a basket for less than 15 minutes
|
||||||
|
is greater or equal than `Product.clic_limit`.
|
||||||
|
"""
|
||||||
|
# import here to avoid circular import
|
||||||
|
from eboutic.models import BasketItem
|
||||||
|
|
||||||
|
nb_click_subquery = Subquery(
|
||||||
|
Selling.objects.filter(product_id=OuterRef("id"))
|
||||||
|
.values("product_id")
|
||||||
|
.annotate(res=Sum("quantity", default=0))
|
||||||
|
.values("res")[:1]
|
||||||
|
)
|
||||||
|
nb_basket_items_subquery = Subquery(
|
||||||
|
BasketItem.objects.filter(
|
||||||
|
product_id=OuterRef("id"),
|
||||||
|
basket__date__gt=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT,
|
||||||
|
)
|
||||||
|
.values("product_id")
|
||||||
|
.annotate(res=Sum("quantity"))
|
||||||
|
.values("res")[:1]
|
||||||
|
)
|
||||||
|
return self.annotate(
|
||||||
|
clicked=Coalesce(nb_click_subquery, 0),
|
||||||
|
reserved=Coalesce(nb_basket_items_subquery, 0),
|
||||||
|
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
|
||||||
|
|
||||||
|
|
||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
"""A product, with all its related information."""
|
"""A product, with all its related information."""
|
||||||
|
|
||||||
QUANTITY_FOR_TRAY_PRICE = 6
|
QUANTITY_FOR_TRAY_PRICE = 6
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
description = models.TextField(_("description"), default="")
|
description = models.TextField(_("description"), blank=True, default="")
|
||||||
product_type = models.ForeignKey(
|
product_type = models.ForeignKey(
|
||||||
ProductType,
|
ProductType,
|
||||||
related_name="products",
|
related_name="products",
|
||||||
verbose_name=_("product type"),
|
verbose_name=_("product type"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=False,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||||
purchase_price = CurrencyField(
|
purchase_price = CurrencyField(
|
||||||
_("purchase price"),
|
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||||
help_text=_("Initial cost of purchasing the product"),
|
|
||||||
)
|
|
||||||
selling_price = CurrencyField(_("selling price"))
|
|
||||||
special_selling_price = CurrencyField(
|
|
||||||
_("special selling price"),
|
|
||||||
help_text=_("Price for barmen during their permanence"),
|
|
||||||
)
|
)
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70,
|
height=70,
|
||||||
@@ -394,14 +414,24 @@ class Product(models.Model):
|
|||||||
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
limit_age = models.IntegerField(_("limit age"), default=0)
|
limit_age = models.IntegerField(_("limit age"), default=0)
|
||||||
tray = models.BooleanField(_("tray price"), default=False)
|
tray = models.BooleanField(
|
||||||
buying_groups = models.ManyToManyField(
|
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
)
|
||||||
|
clic_limit = models.PositiveSmallIntegerField(
|
||||||
|
_("clic limit"),
|
||||||
|
help_text=_(
|
||||||
|
"If a limit is set, the product won't be purchasable "
|
||||||
|
"anymore on the eboutic once the latter is reached."
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
archived = models.BooleanField(_("archived"), default=False)
|
archived = models.BooleanField(_("archived"), default=False)
|
||||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
|
objects = ProductQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("product")
|
verbose_name = _("product")
|
||||||
|
|
||||||
@@ -419,41 +449,77 @@ class Product(models.Model):
|
|||||||
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
||||||
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
def can_be_sold_to(self, user: User) -> bool:
|
|
||||||
"""Check if whether the user given in parameter has the right to buy
|
|
||||||
this product or not.
|
|
||||||
|
|
||||||
This must be not confused with the Customer.can_buy()
|
class PriceQuerySet(models.QuerySet):
|
||||||
method as the present method returns an information
|
def for_user(self, user: User) -> Self:
|
||||||
about the relation between a User and a Product,
|
age = user.age
|
||||||
whereas the other tells something about a Customer
|
if user.is_banned_alcohol:
|
||||||
(and not a user, they are not the same model).
|
age = min(age, 17)
|
||||||
|
return self.filter(
|
||||||
|
Q(is_always_shown=True, groups__in=user.all_groups)
|
||||||
|
| Q(
|
||||||
|
id=Subquery(
|
||||||
|
Price.objects.filter(
|
||||||
|
product_id=OuterRef("product_id"), groups__in=user.all_groups
|
||||||
|
)
|
||||||
|
.order_by("amount")
|
||||||
|
.values("id")[:1]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
product__archived=False,
|
||||||
|
product__limit_age__lte=age,
|
||||||
|
).distinct()
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the user can buy this product else False
|
|
||||||
|
|
||||||
Warning:
|
class Price(models.Model):
|
||||||
This performs a db query, thus you can quickly have
|
amount = CurrencyField(_("amount"))
|
||||||
a N+1 queries problem if you call it in a loop.
|
product = models.ForeignKey(
|
||||||
Hopefully, you can avoid that if you prefetch the buying_groups :
|
Product,
|
||||||
|
verbose_name=_("product"),
|
||||||
|
related_name="prices",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group, verbose_name=_("groups"), related_name="prices"
|
||||||
|
)
|
||||||
|
is_always_shown = models.BooleanField(
|
||||||
|
_("always show"),
|
||||||
|
help_text=_(
|
||||||
|
"If this option is enabled, "
|
||||||
|
"people will see this price and be able to pay it, "
|
||||||
|
"even if another cheaper price exists. "
|
||||||
|
"Else it will visible only if it is the cheapest available price."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
label = models.CharField(
|
||||||
|
_("label"),
|
||||||
|
help_text=_(
|
||||||
|
"A short label for easier differentiation "
|
||||||
|
"if a user can see multiple prices."
|
||||||
|
),
|
||||||
|
max_length=32,
|
||||||
|
default="",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
```python
|
objects = PriceQuerySet.as_manager()
|
||||||
user = User.objects.get(username="foobar")
|
|
||||||
products = [
|
class Meta:
|
||||||
p
|
verbose_name = _("price")
|
||||||
for p in Product.objects.prefetch_related("buying_groups")
|
|
||||||
if p.can_be_sold_to(user)
|
def __str__(self):
|
||||||
]
|
if not self.label:
|
||||||
```
|
return f"{self.product.name} ({self.amount}€)"
|
||||||
"""
|
return f"{self.product.name} {self.label} ({self.amount}€)"
|
||||||
buying_groups = list(self.buying_groups.all())
|
|
||||||
if not buying_groups:
|
|
||||||
return True
|
|
||||||
return any(user.is_in_group(pk=group.id) for group in buying_groups)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def profit(self):
|
def full_label(self):
|
||||||
return self.selling_price - self.purchase_price
|
if not self.label:
|
||||||
|
return self.product.name
|
||||||
|
return f"{self.product.name} \u2013 {self.label}"
|
||||||
|
|
||||||
|
|
||||||
class ProductFormula(models.Model):
|
class ProductFormula(models.Model):
|
||||||
@@ -474,18 +540,6 @@ class ProductFormula(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.result.name
|
return self.result.name
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def max_selling_price(self) -> float:
|
|
||||||
# iterating over all products is less efficient than doing
|
|
||||||
# a simple aggregation, but this method is likely to be used in
|
|
||||||
# coordination with `max_special_selling_price`,
|
|
||||||
# and Django caches the result of the `all` queryset.
|
|
||||||
return sum(p.selling_price for p in self.products.all())
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def max_special_selling_price(self) -> float:
|
|
||||||
return sum(p.special_selling_price for p in self.products.all())
|
|
||||||
|
|
||||||
|
|
||||||
class CounterQuerySet(models.QuerySet):
|
class CounterQuerySet(models.QuerySet):
|
||||||
def annotate_has_barman(self, user: User) -> Self:
|
def annotate_has_barman(self, user: User) -> Self:
|
||||||
@@ -583,7 +637,7 @@ class Counter(models.Model):
|
|||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
mem = self.club.get_membership_for(user)
|
mem = self.club.get_membership_for(user)
|
||||||
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
if mem and mem.role.is_presidency:
|
||||||
return True
|
return True
|
||||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
@@ -716,35 +770,15 @@ class Counter(models.Model):
|
|||||||
# but they share the same primary key
|
# but they share the same primary key
|
||||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||||
|
|
||||||
def get_products_for(self, customer: Customer) -> list[Product]:
|
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||||
"""
|
return (
|
||||||
Get all allowed products for the provided customer on this counter
|
Price.objects.filter(
|
||||||
Prices will be annotated
|
product__counters=self, product__product_type__isnull=False
|
||||||
"""
|
)
|
||||||
|
.for_user(customer.user)
|
||||||
products = (
|
.select_related("product", "product__product_type")
|
||||||
self.products.filter(archived=False)
|
.prefetch_related("groups")
|
||||||
.select_related("product_type")
|
|
||||||
.prefetch_related("buying_groups")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only include age appropriate products
|
|
||||||
age = customer.user.age
|
|
||||||
if customer.user.is_banned_alcohol:
|
|
||||||
age = min(age, 17)
|
|
||||||
products = products.filter(limit_age__lte=age)
|
|
||||||
|
|
||||||
# Compute special price for customer if he is a barmen on that bar
|
|
||||||
if self.customer_is_barman(customer):
|
|
||||||
products = products.annotate(price=F("special_selling_price"))
|
|
||||||
else:
|
|
||||||
products = products.annotate(price=F("selling_price"))
|
|
||||||
|
|
||||||
return [
|
|
||||||
product
|
|
||||||
for product in products.all()
|
|
||||||
if product.can_be_sold_to(customer.user)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CounterSellers(models.Model):
|
class CounterSellers(models.Model):
|
||||||
@@ -1025,7 +1059,9 @@ class Selling(models.Model):
|
|||||||
event = self.product.eticket.event_title or _("Unknown event")
|
event = self.product.eticket.event_title or _("Unknown event")
|
||||||
subject = _("Eticket bought for the event %(event)s") % {"event": event}
|
subject = _("Eticket bought for the event %(event)s") % {"event": event}
|
||||||
message_html = _(
|
message_html = _(
|
||||||
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
|
"You bought an eticket for the event %(event)s.\n"
|
||||||
|
"You can download it directly from this link %(eticket)s.\n"
|
||||||
|
"You can also retrieve all your e-tickets on your account page %(url)s."
|
||||||
) % {
|
) % {
|
||||||
"event": event,
|
"event": event,
|
||||||
"url": (
|
"url": (
|
||||||
|
|||||||
+9
-4
@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
|
|||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
|
|
||||||
from club.schemas import SimpleClubSchema
|
from club.schemas import SimpleClubSchema
|
||||||
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
from counter.models import Counter, Product, ProductType
|
from counter.models import Counter, Price, Product, ProductType
|
||||||
|
|
||||||
|
|
||||||
class CounterSchema(ModelSchema):
|
class CounterSchema(ModelSchema):
|
||||||
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
|
|||||||
fields = ["id", "name", "code"]
|
fields = ["id", "name", "code"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductPriceSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Price
|
||||||
|
fields = ["amount", "groups"]
|
||||||
|
|
||||||
|
|
||||||
class ProductSchema(ModelSchema):
|
class ProductSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
|
|||||||
"code",
|
"code",
|
||||||
"description",
|
"description",
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
|
||||||
"icon",
|
"icon",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
|
|
||||||
buying_groups: list[GroupSchema]
|
prices: list[ProductPriceSchema]
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
product_type: SimpleProductTypeSchema | None
|
product_type: SimpleProductTypeSchema | None
|
||||||
url: str
|
url: str
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { Product } from "#counter:counter/types.ts";
|
import type { CounterItem } from "#counter:counter/types";
|
||||||
|
|
||||||
export class BasketItem {
|
export class BasketItem {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
product: Product;
|
product: CounterItem;
|
||||||
quantityForTrayPrice: number;
|
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
||||||
constructor(product: Product, quantity: number) {
|
constructor(product: CounterItem, quantity: number) {
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.product = product;
|
this.product = product;
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
@@ -20,6 +19,6 @@ export class BasketItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sum(): number {
|
sum(): number {
|
||||||
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { AlertMessage } from "#core:utils/alert-message.ts";
|
import { AlertMessage } from "#core:utils/alert-message";
|
||||||
import { BasketItem } from "#counter:counter/basket.ts";
|
import { BasketItem } from "#counter:counter/basket";
|
||||||
import type {
|
import type {
|
||||||
CounterConfig,
|
CounterConfig,
|
||||||
|
CounterItem,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
} from "#counter:counter/types.ts";
|
} from "#counter:counter/types";
|
||||||
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
|
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("counter", (config: CounterConfig) => ({
|
Alpine.data("counter", (config: CounterConfig) => ({
|
||||||
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
checkFormulas() {
|
checkFormulas() {
|
||||||
|
// Try to find a formula.
|
||||||
|
// A formula is found if all its elements are already in the basket
|
||||||
const products = new Set(
|
const products = new Set(
|
||||||
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
|
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||||
);
|
);
|
||||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
||||||
return f.products.every((p: number) => products.has(p));
|
return f.products.every((p: number) => products.has(p));
|
||||||
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (formula === undefined) {
|
if (formula === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Now that the formula is found, remove the items composing it from the basket
|
||||||
for (const product of formula.products) {
|
for (const product of formula.products) {
|
||||||
const key = product.toString();
|
const key = Object.entries(this.basket).find(
|
||||||
|
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
||||||
|
)[0];
|
||||||
this.basket[key].quantity -= 1;
|
this.basket[key].quantity -= 1;
|
||||||
if (this.basket[key].quantity <= 0) {
|
if (this.basket[key].quantity <= 0) {
|
||||||
this.removeFromBasket(key);
|
this.removeFromBasket(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Then add the result product of the formula to the basket
|
||||||
|
const result = Object.values(config.products)
|
||||||
|
.filter((item: CounterItem) => item.productId === formula.result)
|
||||||
|
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
||||||
|
this.addToBasket(result.price.id, 1);
|
||||||
this.alertMessage.display(
|
this.alertMessage.display(
|
||||||
interpolate(
|
interpolate(
|
||||||
gettext("Formula %(formula)s applied"),
|
gettext("Formula %(formula)s applied"),
|
||||||
{ formula: config.products[formula.result.toString()].name },
|
{ formula: result.name },
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
{ success: true },
|
{ success: true },
|
||||||
);
|
);
|
||||||
this.addToBasket(formula.result.toString(), 1);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getBasketSize() {
|
getBasketSize() {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { showSaveFilePicker } from "native-file-system-adapter";
|
import { showSaveFilePicker } from "native-file-system-adapter";
|
||||||
import type TomSelect from "tom-select";
|
import type TomSelect from "tom-select";
|
||||||
import { paginated } from "#core:utils/api.ts";
|
import { paginated } from "#core:utils/api";
|
||||||
import { csv } from "#core:utils/csv.ts";
|
import { csv } from "#core:utils/csv";
|
||||||
import {
|
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
|
||||||
getCurrentUrlParams,
|
import type { NestedKeyOf } from "#core:utils/types";
|
||||||
History,
|
|
||||||
updateQueryString,
|
|
||||||
} from "#core:utils/history.ts";
|
|
||||||
import type { NestedKeyOf } from "#core:utils/types.ts";
|
|
||||||
import {
|
import {
|
||||||
type ProductSchema,
|
type ProductSchema,
|
||||||
type ProductSearchProductsDetailedData,
|
type ProductSearchProductsDetailedData,
|
||||||
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
|
|||||||
const defaultPageSize = 100;
|
const defaultPageSize = 100;
|
||||||
const defaultPage = 1;
|
const defaultPage = 1;
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||||
|
type ProductWithPriceSchema = ProductSchema & { selling_price: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys of the properties to include in the CSV.
|
* Keys of the properties to include in the CSV.
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +33,7 @@ const csvColumns = [
|
|||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
"selling_price",
|
||||||
"archived",
|
"archived",
|
||||||
] as NestedKeyOf<ProductSchema>[];
|
] as NestedKeyOf<ProductWithPriceSchema>[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Title of the csv columns.
|
* Title of the csv columns.
|
||||||
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.nbPages > 1
|
this.nbPages > 1
|
||||||
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||||
: Object.values<ProductSchema[]>(this.products).flat();
|
: Object.values<ProductSchema[]>(this.products).flat();
|
||||||
const content = csv.stringify(products, {
|
// CSV cannot represent nested data
|
||||||
|
// so we create a row for each price of each product.
|
||||||
|
const productsWithPrice: ProductWithPriceSchema[] = products.flatMap(
|
||||||
|
(product: ProductSchema) =>
|
||||||
|
product.prices.map((price) =>
|
||||||
|
// biome-ignore lint/style/useNamingConvention: API is snake_case
|
||||||
|
Object.assign(product, { selling_price: price.amount }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const content = csv.stringify(productsWithPrice, {
|
||||||
columns: csvColumns,
|
columns: csvColumns,
|
||||||
titleRow: csvColumnTitles,
|
titleRow: csvColumnTitles,
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-5
@@ -2,7 +2,7 @@ export type ErrorMessage = string;
|
|||||||
|
|
||||||
export interface InitialFormData {
|
export interface InitialFormData {
|
||||||
/* Used to refill the form when the backend raises an error */
|
/* Used to refill the form when the backend raises an error */
|
||||||
id?: keyof Record<string, Product>;
|
id?: keyof Record<string, CounterItem>;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
}
|
}
|
||||||
@@ -15,17 +15,22 @@ export interface ProductFormula {
|
|||||||
export interface CounterConfig {
|
export interface CounterConfig {
|
||||||
customerBalance: number;
|
customerBalance: number;
|
||||||
customerId: number;
|
customerId: number;
|
||||||
products: Record<string, Product>;
|
products: Record<string, CounterItem>;
|
||||||
formulas: ProductFormula[];
|
formulas: ProductFormula[];
|
||||||
formInitial: InitialFormData[];
|
formInitial: InitialFormData[];
|
||||||
cancelUrl: string;
|
cancelUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
interface Price {
|
||||||
id: string;
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterItem {
|
||||||
|
productId: number;
|
||||||
|
price: Price;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
hasTrayPrice: boolean;
|
hasTrayPrice: boolean;
|
||||||
quantityForTrayPrice: number;
|
quantityForTrayPrice: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||||
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{%- for category in categories.keys() -%}
|
{%- for category, prices in categories.items() -%}
|
||||||
<optgroup label="{{ category }}">
|
<optgroup label="{{ category }}">
|
||||||
{%- for product in categories[category] -%}
|
{%- for price in prices -%}
|
||||||
<option value="{{ product.id }}">{{ product }}</option>
|
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
@@ -103,24 +103,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
|
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||||
<li>
|
<li>
|
||||||
<template x-for="error in item.errors">
|
<template x-for="error in item.errors">
|
||||||
<div class="alert alert-red" x-text="error">
|
<div class="alert alert-red" x-text="error">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
|
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||||
<span class="quantity" x-text="item.quantity"></span>
|
<span class="quantity" x-text="item.quantity"></span>
|
||||||
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
|
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||||
|
|
||||||
<span x-text="item.product.name"></span> :
|
<span x-text="item.product.name"></span> :
|
||||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
<span x-show="item.getBonusQuantity() > 0"
|
||||||
|
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="remove-item"
|
class="remove-item"
|
||||||
@click.prevent="removeFromBasket(item.product.id)"
|
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||||
><i class="fa fa-trash-can delete-action"></i></button>
|
><i class="fa fa-trash-can delete-action"></i></button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -133,9 +134,9 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
:value="item.product.id"
|
:value="item.product.price.id"
|
||||||
:id="`id_form-${index}-id`"
|
:id="`id_form-${index}-price_id`"
|
||||||
:name="`form-${index}-id`"
|
:name="`form-${index}-price_id`"
|
||||||
required
|
required
|
||||||
readonly
|
readonly
|
||||||
>
|
>
|
||||||
@@ -201,30 +202,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="products">
|
<div id="products">
|
||||||
{% if not products %}
|
{% if not prices %}
|
||||||
<div class="alert alert-red">
|
<div class="alert alert-red">
|
||||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ui-tab-group>
|
<ui-tab-group>
|
||||||
{% for category in categories.keys() -%}
|
{% for category, prices in categories.items() -%}
|
||||||
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
||||||
<h5 class="margin-bottom">{{ category }}</h5>
|
<h5 class="margin-bottom">{{ category }}</h5>
|
||||||
<div class="row gap-2x">
|
<div class="row gap-2x">
|
||||||
{% for product in categories[category] -%}
|
{% for price in prices -%}
|
||||||
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
|
<button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
|
||||||
<img
|
<img
|
||||||
class="card-image"
|
class="card-image"
|
||||||
alt="image de {{ product.name }}"
|
alt="image de {{ price.full_label }}"
|
||||||
{% if product.icon %}
|
{% if price.product.icon %}
|
||||||
src="{{ product.icon.url }}"
|
src="{{ price.product.icon.url }}"
|
||||||
{% else %}
|
{% else %}
|
||||||
src="{{ static('core/img/na.gif') }}"
|
src="{{ static('core/img/na.gif') }}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
/>
|
/>
|
||||||
<span class="card-content">
|
<span class="card-content">
|
||||||
<strong class="card-title">{{ product.name }}</strong>
|
<strong class="card-title">{{ price.full_label }}</strong>
|
||||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
<p>{{ price.amount }} €<br>{{ price.product.code }}</p>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -241,13 +242,14 @@
|
|||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
const products = {
|
const products = {
|
||||||
{%- for product in products -%}
|
{%- for price in prices -%}
|
||||||
{{ product.id }}: {
|
{{ price.id }}: {
|
||||||
id: "{{ product.id }}",
|
productId: {{ price.product_id }},
|
||||||
name: "{{ product.name }}",
|
price: { id: "{{ price.id }}", amount: {{ price.amount }} },
|
||||||
price: {{ product.price }},
|
code: "{{ price.product.code }}",
|
||||||
hasTrayPrice: {{ product.tray | tojson }},
|
name: "{{ price.full_label }}",
|
||||||
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
|
hasTrayPrice: {{ price.product.tray | tojson }},
|
||||||
|
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
|
||||||
},
|
},
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,14 +49,10 @@
|
|||||||
<strong class="card-title">{{ formula.result.name }}</strong>
|
<strong class="card-title">{{ formula.result.name }}</strong>
|
||||||
<p>
|
<p>
|
||||||
{% for p in formula.products.all() %}
|
{% for p in formula.products.all() %}
|
||||||
<i>{{ p.code }} ({{ p.selling_price }} €)</i>
|
<i>{{ p.name }} ({{ p.code }})</i>
|
||||||
{% if not loop.last %}+{% endif %}
|
{% if not loop.last %}+{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
{{ formula.result.selling_price }} €
|
|
||||||
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{% if user.has_perm("counter.delete_productformula") %}
|
{% if user.has_perm("counter.delete_productformula") %}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -39,6 +39,49 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro price_form(form) %}
|
||||||
|
<fieldset>
|
||||||
|
{{ form.non_field_errors() }}
|
||||||
|
<div class="form-group row gap-2x">
|
||||||
|
<div>{{ form.amount.as_field_group() }}</div>
|
||||||
|
<div>
|
||||||
|
{{ form.label.errors }}
|
||||||
|
<label for="{{ form.label.id_for_label }}">{{ form.label.label }}</label>
|
||||||
|
{{ form.label }}
|
||||||
|
<span class="helptext">{{ form.label.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grow">{{ form.groups.as_field_group() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div>
|
||||||
|
{{ form.is_always_shown.errors }}
|
||||||
|
<div class="row gap">
|
||||||
|
{{ form.is_always_shown }}
|
||||||
|
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
|
||||||
|
</div>
|
||||||
|
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- if form.DELETE -%}
|
||||||
|
<div class="form-group row gap">
|
||||||
|
{{ form.DELETE.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<br>
|
||||||
|
<button
|
||||||
|
class="btn btn-grey"
|
||||||
|
@click.prevent="removeForm($event.target.closest('fieldset').parentElement)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-minus"></i> {% trans %}Remove price{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for field in form.hidden_fields() -%}
|
||||||
|
{{ field }}
|
||||||
|
{%- endfor -%}
|
||||||
|
</fieldset>
|
||||||
|
<hr class="margin-bottom">
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if object %}
|
{% if object %}
|
||||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||||
@@ -49,7 +92,55 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form.non_field_errors() }}
|
||||||
|
<fieldset class="row gap">
|
||||||
|
<div>{{ form.name.as_field_group() }}</div>
|
||||||
|
<div>{{ form.code.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group">{{ form.description.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="row gap">
|
||||||
|
<div>{{ form.club.as_field_group() }}</div>
|
||||||
|
<div>{{ form.product_type.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.icon.as_field_group() }}</div></fieldset>
|
||||||
|
<fieldset><div>{{ form.purchase_price.as_field_group() }}</div></fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div>{{ form.limit_age.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row gap">
|
||||||
|
{{ form.tray }}
|
||||||
|
<div>
|
||||||
|
{{ form.tray.label_tag() }}
|
||||||
|
<span class="helptext">{{ form.tray.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||||
|
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||||
|
|
||||||
|
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||||
|
|
||||||
|
<div x-data="dynamicFormSet({ prefix: '{{ form.price_formset.prefix }}' })">
|
||||||
|
{{ form.price_formset.management_form }}
|
||||||
|
<div x-ref="formContainer">
|
||||||
|
{%- for form in form.price_formset.forms -%}
|
||||||
|
<div>
|
||||||
|
{{ price_form(form) }}
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
<template x-ref="formTemplate">
|
||||||
|
<div>
|
||||||
|
{{ price_form(form.price_formset.empty_form) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -64,7 +155,7 @@
|
|||||||
</em>
|
</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div x-data="dynamicFormSet" class="margin-bottom">
|
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
|
||||||
{{ form.action_formset.management_form }}
|
{{ form.action_formset.management_form }}
|
||||||
<div x-ref="formContainer">
|
<div x-ref="formContainer">
|
||||||
{%- for f in form.action_formset.forms -%}
|
{%- for f in form.action_formset.forms -%}
|
||||||
@@ -78,6 +169,7 @@
|
|||||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<span class="card-content">
|
<span class="card-content">
|
||||||
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
||||||
<p x-text="`${p.selling_price} €`"></p>
|
<p x-text="`${p.prices.map((p) => p.amount).join(' – ')} €`"></p>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from counter.forms import (
|
|||||||
ScheduledProductActionForm,
|
ScheduledProductActionForm,
|
||||||
ScheduledProductActionFormSet,
|
ScheduledProductActionFormSet,
|
||||||
)
|
)
|
||||||
from counter.models import Product, ScheduledProductAction
|
from counter.models import Product, ProductType, ScheduledProductAction
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -47,18 +47,19 @@ def test_create_actions_alongside_product():
|
|||||||
form = ProductForm(
|
form = ProductForm(
|
||||||
data={
|
data={
|
||||||
"name": "foo",
|
"name": "foo",
|
||||||
"description": "bar",
|
"product_type": ProductType.objects.first(),
|
||||||
"product_type": product.product_type_id,
|
|
||||||
"club": product.club_id,
|
"club": product.club_id,
|
||||||
"code": "FOO",
|
"code": "FOO",
|
||||||
"purchase_price": 1.0,
|
"purchase_price": 1.0,
|
||||||
"selling_price": 1.0,
|
"selling_price": 1.0,
|
||||||
"special_selling_price": 1.0,
|
"special_selling_price": 1.0,
|
||||||
"limit_age": 0,
|
"limit_age": 0,
|
||||||
"form-TOTAL_FORMS": "2",
|
"price-TOTAL_FORMS": "0",
|
||||||
"form-INITIAL_FORMS": "0",
|
"price-INITIAL_FORMS": "0",
|
||||||
"form-0-task": "counter.tasks.archive_product",
|
"action-TOTAL_FORMS": "1",
|
||||||
"form-0-trigger_at": trigger_at,
|
"action-INITIAL_FORMS": "0",
|
||||||
|
"action-0-task": "counter.tasks.archive_product",
|
||||||
|
"action-0-trigger_at": trigger_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import pytest
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission, make_password
|
from django.contrib.auth.models import Permission, make_password
|
||||||
from django.core.cache import cache
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import resolve_url
|
from django.shortcuts import resolve_url
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
@@ -32,15 +31,15 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import ClubRole, 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, Group, User
|
||||||
from counter.baker_recipes import product_recipe, sale_recipe
|
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
Product,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
Selling,
|
Selling,
|
||||||
@@ -88,7 +87,7 @@ class TestFullClickBase(TestCase):
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BasketItem:
|
class BasketItem:
|
||||||
id: int | None = None
|
price_id: int | None = None
|
||||||
quantity: int | None = None
|
quantity: int | None = None
|
||||||
|
|
||||||
def to_form(self, index: int) -> dict[str, str]:
|
def to_form(self, index: int) -> dict[str, str]:
|
||||||
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
cls.banned_counter_customer.ban_groups.add(
|
cls.banned_counter_customer.ban_groups.add(
|
||||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||||
)
|
)
|
||||||
|
subscriber_group = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||||
|
old_subscriber_group = Group.objects.get(
|
||||||
|
id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
|
||||||
|
)
|
||||||
|
_product_recipe = product_recipe.extend(product_type=baker.make(ProductType))
|
||||||
|
|
||||||
cls.gift = product_recipe.make(
|
cls.gift = price_recipe.make(
|
||||||
selling_price="-1.5",
|
amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
special_selling_price="-1.5",
|
|
||||||
)
|
)
|
||||||
cls.beer = product_recipe.make(
|
cls.beer = price_recipe.make(
|
||||||
limit_age=18, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=18),
|
||||||
)
|
)
|
||||||
cls.beer_tap = product_recipe.make(
|
cls.beer_tap = price_recipe.make(
|
||||||
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=18, tray=True),
|
||||||
)
|
)
|
||||||
cls.snack = product_recipe.make(
|
cls.snack = price_recipe.make(
|
||||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group, old_subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=0),
|
||||||
)
|
)
|
||||||
cls.stamps = product_recipe.make(
|
cls.stamps = price_recipe.make(
|
||||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=0),
|
||||||
)
|
)
|
||||||
ReturnableProduct.objects.all().delete()
|
ReturnableProduct.objects.all().delete()
|
||||||
cls.cons = baker.make(Product, selling_price=1)
|
cls.cons = price_recipe.make(
|
||||||
cls.dcons = baker.make(Product, selling_price=-1)
|
amount=1, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
|
)
|
||||||
|
cls.dcons = price_recipe.make(
|
||||||
|
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
|
)
|
||||||
baker.make(
|
baker.make(
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
product=cls.cons,
|
product=cls.cons.product,
|
||||||
returned_product=cls.dcons,
|
returned_product=cls.dcons.product,
|
||||||
max_return=3,
|
max_return=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.counter.products.add(
|
cls.counter.products.add(
|
||||||
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
|
cls.gift.product,
|
||||||
|
cls.beer.product,
|
||||||
|
cls.beer_tap.product,
|
||||||
|
cls.snack.product,
|
||||||
|
cls.cons.product,
|
||||||
|
cls.dcons.product,
|
||||||
)
|
)
|
||||||
cls.other_counter.products.add(cls.snack)
|
cls.other_counter.products.add(cls.snack.product)
|
||||||
cls.club_counter.products.add(cls.stamps)
|
cls.club_counter.products.add(cls.stamps.product)
|
||||||
|
|
||||||
def login_in_bar(self, barmen: User | None = None):
|
def login_in_bar(self, barmen: User | None = None):
|
||||||
used_barman = barmen if barmen is not None else self.barmen
|
used_barman = barmen if barmen is not None else self.barmen
|
||||||
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
used_counter = counter if counter is not None else self.counter
|
used_counter = counter if counter is not None else self.counter
|
||||||
used_client = client if client is not None else self.client
|
used_client = client if client is not None else self.client
|
||||||
data = {
|
data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
|
||||||
"form-TOTAL_FORMS": str(len(basket)),
|
|
||||||
"form-INITIAL_FORMS": "0",
|
|
||||||
}
|
|
||||||
for index, item in enumerate(basket):
|
for index, item in enumerate(basket):
|
||||||
data.update(item.to_form(index))
|
data.update(item.to_form(index))
|
||||||
return used_client.post(
|
return used_client.post(
|
||||||
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||||
)
|
)
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal("5.5")
|
assert self.updated_amount(self.customer) == Decimal("5.5")
|
||||||
|
|
||||||
# Test barmen special price
|
|
||||||
|
|
||||||
force_refill_user(self.barmen, 10)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
|
||||||
).status_code == 302
|
|
||||||
|
|
||||||
assert self.updated_amount(self.barmen) == Decimal(9)
|
|
||||||
|
|
||||||
def test_click_tray_price(self):
|
def test_click_tray_price(self):
|
||||||
force_refill_user(self.customer, 20)
|
force_refill_user(self.customer, 20)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
|
|
||||||
# Not applying tray price
|
# Not applying tray price
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
assert self.updated_amount(self.customer) == Decimal(17)
|
assert self.updated_amount(self.customer) == Decimal(17)
|
||||||
|
|
||||||
# Applying tray price
|
# Applying tray price
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
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):
|
||||||
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
BasketItem(None, 1),
|
BasketItem(None, 1),
|
||||||
BasketItem(self.beer.id, None),
|
BasketItem(self.beer.id, None),
|
||||||
]:
|
]:
|
||||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
res = self.submit_basket(self.customer, [item])
|
||||||
|
assert res.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):
|
||||||
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
||||||
)
|
)
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_recordings(self):
|
def test_recordings(self):
|
||||||
force_refill_user(self.customer, self.cons.selling_price * 3)
|
force_refill_user(self.customer, self.cons.amount * 3)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
assert list(
|
assert list(
|
||||||
self.customer.customer.return_balances.values("returnable", "balance")
|
self.customer.customer.return_balances.values("returnable", "balance")
|
||||||
) == [{"returnable": self.cons.cons.id, "balance": 3}]
|
) == [{"returnable": self.cons.product.cons.id, "balance": 3}]
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
|
assert self.updated_amount(self.customer) == self.dcons.amount * -3
|
||||||
|
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
|
self.customer,
|
||||||
|
[BasketItem(self.dcons.id, self.dcons.product.dcons.max_return)],
|
||||||
)
|
)
|
||||||
# from now on, the user amount should not change
|
# from now on, the user amount should not change
|
||||||
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
|
expected_amount = self.dcons.amount * (-3 - self.dcons.product.dcons.max_return)
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == expected_amount
|
assert self.updated_amount(self.customer) == expected_amount
|
||||||
|
|
||||||
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
def test_recordings_when_negative(self):
|
def test_recordings_when_negative(self):
|
||||||
sale_recipe.make(
|
sale_recipe.make(
|
||||||
customer=self.customer.customer,
|
customer=self.customer.customer,
|
||||||
product=self.dcons,
|
product=self.dcons.product,
|
||||||
unit_price=self.dcons.selling_price,
|
unit_price=self.dcons.amount,
|
||||||
quantity=10,
|
quantity=10,
|
||||||
)
|
)
|
||||||
self.customer.customer.update_returnable_balance()
|
self.customer.customer.update_returnable_balance()
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
|
assert self.updated_amount(self.customer) == self.dcons.amount * -10
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert (
|
assert (
|
||||||
self.updated_amount(self.customer)
|
self.updated_amount(self.customer)
|
||||||
== self.dcons.selling_price * -10 - self.cons.selling_price * 3
|
== self.dcons.amount * -10 - self.cons.amount * 3
|
||||||
)
|
)
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert (
|
assert (
|
||||||
self.updated_amount(self.customer)
|
self.updated_amount(self.customer)
|
||||||
== self.dcons.selling_price * -10
|
== self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
|
||||||
- self.cons.selling_price * 3
|
|
||||||
- self.beer.selling_price
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_no_fetch_archived_product(self):
|
def test_no_fetch_archived_product(self):
|
||||||
counter = baker.make(Counter)
|
counter = baker.make(Counter)
|
||||||
|
group = baker.make(Group)
|
||||||
customer = baker.make(Customer)
|
customer = baker.make(Customer)
|
||||||
product_recipe.make(archived=True, counters=[counter])
|
group.users.add(customer.user)
|
||||||
unarchived_products = product_recipe.make(
|
_product_recipe = product_recipe.extend(
|
||||||
archived=False, counters=[counter], _quantity=3
|
counters=[counter], product_type=baker.make(ProductType)
|
||||||
)
|
)
|
||||||
customer_products = counter.get_products_for(customer)
|
price_recipe.make(
|
||||||
assert unarchived_products == customer_products
|
_quantity=2,
|
||||||
|
product=iter(_product_recipe.make(archived=True, _quantity=2)),
|
||||||
|
groups=[group],
|
||||||
|
)
|
||||||
|
unarchived_prices = price_recipe.make(
|
||||||
|
_quantity=2,
|
||||||
|
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||||
|
groups=[group],
|
||||||
|
)
|
||||||
|
customer_prices = list(counter.get_prices_for(customer))
|
||||||
|
assert unarchived_prices == customer_prices
|
||||||
|
|
||||||
|
|
||||||
class TestCounterStats(TestCase):
|
class TestCounterStats(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.users = subscriber_user.make(_quantity=4)
|
cls.users = subscriber_user.make(_quantity=4)
|
||||||
product = product_recipe.make(selling_price=1)
|
product = price_recipe.make(amount=1).product
|
||||||
cls.counter = baker.make(
|
cls.counter = baker.make(
|
||||||
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
|
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
|
||||||
)
|
)
|
||||||
@@ -782,12 +800,15 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
"counter:click",
|
"counter:click",
|
||||||
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
||||||
)
|
)
|
||||||
|
cls.board_role, cls.member_role = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
club=cls.counter.club,
|
||||||
|
is_board=iter([True, False]),
|
||||||
|
_quantity=2,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
def test_anonymous(self):
|
def test_anonymous(self):
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
@@ -797,13 +818,17 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
# being a member of the club, without being in the board, isn't enough
|
# being a member of the club, without being in the board, isn't enough
|
||||||
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
|
baker.make(
|
||||||
|
Membership, club=self.counter.club, user=self.user, role=self.member_role
|
||||||
|
)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
def test_board_member(self):
|
def test_board_member(self):
|
||||||
"""By default, board members should be able to click on office counters"""
|
"""By default, board members should be able to click on office counters"""
|
||||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
baker.make(
|
||||||
|
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -818,7 +843,9 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
def test_both_barman_and_board_member(self):
|
def test_both_barman_and_board_member(self):
|
||||||
"""If the user is barman and board member, he should be authorized as well."""
|
"""If the user is barman and board member, he should be authorized as well."""
|
||||||
self.counter.sellers.add(self.user)
|
self.counter.sellers.add(self.user)
|
||||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
baker.make(
|
||||||
|
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import string
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.base_user import make_password
|
from django.contrib.auth.base_user import make_password
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import ClubRole, 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 product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||||
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
|
|||||||
cls.counter.sellers.add(cls.barmen)
|
cls.counter.sellers.add(cls.barmen)
|
||||||
|
|
||||||
cls.club_counter = baker.make(Counter)
|
cls.club_counter = baker.make(Counter)
|
||||||
|
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||||
baker.make(
|
baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=role,
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ def test_update_balance():
|
|||||||
def test_update_returnable_balance():
|
def test_update_returnable_balance():
|
||||||
ReturnableProduct.objects.all().delete()
|
ReturnableProduct.objects.all().delete()
|
||||||
customer = baker.make(Customer)
|
customer = baker.make(Customer)
|
||||||
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
|
products = product_recipe.make(_quantity=4, _bulk_create=True)
|
||||||
returnables = [
|
returnables = [
|
||||||
baker.make(
|
baker.make(
|
||||||
ReturnableProduct, product=products[0], returned_product=products[1]
|
ReturnableProduct, product=products[0], returned_product=products[1]
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
|
|||||||
class TestFormulaForm(TestCase):
|
class TestFormulaForm(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.products = product_recipe.make(
|
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
|
||||||
selling_price=iter([1.5, 1, 1]),
|
|
||||||
special_selling_price=iter([1.4, 0.9, 0.9]),
|
|
||||||
_quantity=3,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
form = ProductFormulaForm(
|
form = ProductFormulaForm(
|
||||||
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
|
|||||||
assert formula.result == self.products[0]
|
assert formula.result == self.products[0]
|
||||||
assert set(formula.products.all()) == set(self.products[1:])
|
assert set(formula.products.all()) == set(self.products[1:])
|
||||||
|
|
||||||
def test_price_invalid(self):
|
|
||||||
self.products[0].selling_price = 2.1
|
|
||||||
self.products[0].save()
|
|
||||||
form = ProductFormulaForm(
|
|
||||||
data={
|
|
||||||
"result": self.products[0].id,
|
|
||||||
"products": [self.products[1].id, self.products[2].id],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert not form.is_valid()
|
|
||||||
assert form.errors == {
|
|
||||||
"result": [
|
|
||||||
"Le résultat ne peut pas être plus cher "
|
|
||||||
"que le total des autres produits."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_product_both_in_result_and_products(self):
|
def test_product_both_in_result_and_products(self):
|
||||||
form = ProductFormulaForm(
|
form = ProductFormulaForm(
|
||||||
data={
|
data={
|
||||||
|
|||||||
+159
-38
@@ -1,3 +1,5 @@
|
|||||||
|
import itertools
|
||||||
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,16 +10,19 @@ from django.core.cache import cache
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
from model_bakery.recipe import Recipe
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.baker_recipes import product_recipe
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
from counter.forms import ProductForm
|
from counter.forms import ProductForm, ProductPriceFormSet
|
||||||
from counter.models import Product, ProductFormula, ProductType
|
from counter.models import Price, Product, ProductType, Selling
|
||||||
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -81,11 +86,11 @@ def test_fetch_product_access(
|
|||||||
def test_fetch_product_nb_queries(client: Client):
|
def test_fetch_product_nb_queries(client: Client):
|
||||||
client.force_login(baker.make(User, is_superuser=True))
|
client.force_login(baker.make(User, is_superuser=True))
|
||||||
cache.clear()
|
cache.clear()
|
||||||
with assertNumQueries(5):
|
with assertNumQueries(6):
|
||||||
# - 2 for authentication
|
# - 2 for authentication
|
||||||
# - 1 for pagination
|
# - 1 for pagination
|
||||||
# - 1 for the actual request
|
# - 1 for the actual request
|
||||||
# - 1 to prefetch the related buying_groups
|
# - 2 to prefetch the related prices and groups
|
||||||
client.get(reverse("api:search_products_detailed"))
|
client.get(reverse("api:search_products_detailed"))
|
||||||
|
|
||||||
|
|
||||||
@@ -107,48 +112,21 @@ class TestCreateProduct(TestCase):
|
|||||||
"selling_price": 1.0,
|
"selling_price": 1.0,
|
||||||
"special_selling_price": 1.0,
|
"special_selling_price": 1.0,
|
||||||
"limit_age": 0,
|
"limit_age": 0,
|
||||||
"form-TOTAL_FORMS": 0,
|
"price-TOTAL_FORMS": 0,
|
||||||
"form-INITIAL_FORMS": 0,
|
"price-INITIAL_FORMS": 0,
|
||||||
|
"action-TOTAL_FORMS": 0,
|
||||||
|
"action-INITIAL_FORMS": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_form(self):
|
def test_form_simple(self):
|
||||||
form = ProductForm(data=self.data)
|
form = ProductForm(data=self.data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
instance = form.save()
|
instance = form.save()
|
||||||
assert instance.club == self.club
|
assert instance.club == self.club
|
||||||
assert instance.product_type == self.product_type
|
assert instance.product_type == self.product_type
|
||||||
assert instance.name == "foo"
|
assert instance.name == "foo"
|
||||||
assert instance.selling_price == 1.0
|
|
||||||
|
|
||||||
def test_form_with_product_from_formula(self):
|
def test_view_simple(self):
|
||||||
"""Test when the edited product is a result of a formula."""
|
|
||||||
self.client.force_login(self.counter_admin)
|
|
||||||
products = product_recipe.make(
|
|
||||||
selling_price=iter([1.5, 1, 1]),
|
|
||||||
special_selling_price=iter([1.4, 0.9, 0.9]),
|
|
||||||
_quantity=3,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
baker.make(ProductFormula, result=products[0], products=products[1:])
|
|
||||||
|
|
||||||
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
|
|
||||||
form = ProductForm(data=data, instance=products[0])
|
|
||||||
assert form.is_valid()
|
|
||||||
|
|
||||||
# it shouldn't be possible to give a price higher than the formula's products
|
|
||||||
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
|
|
||||||
form = ProductForm(data=data, instance=products[0])
|
|
||||||
assert not form.is_valid()
|
|
||||||
assert form.errors == {
|
|
||||||
"selling_price": [
|
|
||||||
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
|
|
||||||
],
|
|
||||||
"special_selling_price": [
|
|
||||||
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_view(self):
|
|
||||||
self.client.force_login(self.counter_admin)
|
self.client.force_login(self.counter_admin)
|
||||||
url = reverse("counter:new_product")
|
url = reverse("counter:new_product")
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
@@ -159,3 +137,146 @@ class TestCreateProduct(TestCase):
|
|||||||
assert product.name == "foo"
|
assert product.name == "foo"
|
||||||
assert product.club == self.club
|
assert product.club == self.club
|
||||||
assert product.product_type == self.product_type
|
assert product.product_type == self.product_type
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceFormSet(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.product = product_recipe.make()
|
||||||
|
cls.counter_admin = baker.make(
|
||||||
|
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
|
||||||
|
)
|
||||||
|
cls.groups = baker.make(Group, _quantity=3)
|
||||||
|
|
||||||
|
def test_add_price(self):
|
||||||
|
data = {
|
||||||
|
"prices-0-amount": 2,
|
||||||
|
"prices-0-label": "foo",
|
||||||
|
"prices-0-groups": [self.groups[0].id, self.groups[1].id],
|
||||||
|
"prices-0-is_always_shown": True,
|
||||||
|
"prices-1-amount": 1.5,
|
||||||
|
"prices-1-label": "",
|
||||||
|
"prices-1-groups": [self.groups[1].id, self.groups[2].id],
|
||||||
|
"prices-1-is_always_shown": False,
|
||||||
|
"prices-TOTAL_FORMS": 2,
|
||||||
|
"prices-INITIAL_FORMS": 0,
|
||||||
|
}
|
||||||
|
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
prices = list(self.product.prices.order_by("amount"))
|
||||||
|
assert len(prices) == 2
|
||||||
|
assert prices[0].amount == 1.5
|
||||||
|
assert prices[0].label == ""
|
||||||
|
assert prices[0].is_always_shown is False
|
||||||
|
assert set(prices[0].groups.all()) == {self.groups[1], self.groups[2]}
|
||||||
|
assert prices[1].amount == 2
|
||||||
|
assert prices[1].label == "foo"
|
||||||
|
assert prices[1].is_always_shown is True
|
||||||
|
assert set(prices[1].groups.all()) == {self.groups[0], self.groups[1]}
|
||||||
|
|
||||||
|
def test_change_prices(self):
|
||||||
|
price_a = baker.make(
|
||||||
|
Price, product=self.product, amount=1.5, groups=self.groups[:1]
|
||||||
|
)
|
||||||
|
price_b = baker.make(
|
||||||
|
Price, product=self.product, amount=2, groups=self.groups[1:]
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"prices-0-id": price_a.id,
|
||||||
|
"prices-0-DELETE": True,
|
||||||
|
"prices-1-id": price_b.id,
|
||||||
|
"prices-1-DELETE": False,
|
||||||
|
"prices-1-amount": 3,
|
||||||
|
"prices-1-label": "foo",
|
||||||
|
"prices-1-groups": [self.groups[1].id],
|
||||||
|
"prices-1-is_always_shown": True,
|
||||||
|
"prices-TOTAL_FORMS": 2,
|
||||||
|
"prices-INITIAL_FORMS": 2,
|
||||||
|
}
|
||||||
|
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
prices = list(self.product.prices.order_by("amount"))
|
||||||
|
assert len(prices) == 1
|
||||||
|
assert prices[0].amount == 3
|
||||||
|
assert prices[0].label == "foo"
|
||||||
|
assert prices[0].is_always_shown is True
|
||||||
|
assert set(prices[0].groups.all()) == {self.groups[1]}
|
||||||
|
assert not Price.objects.filter(id=price_a.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_price_for_user():
|
||||||
|
groups = baker.make(Group, _quantity=4)
|
||||||
|
users = [
|
||||||
|
baker.make(User, groups=groups[:2]),
|
||||||
|
baker.make(User, groups=groups[1:3]),
|
||||||
|
baker.make(User, groups=[groups[3]]),
|
||||||
|
]
|
||||||
|
recipe = Recipe(Price, product=product_recipe.make())
|
||||||
|
prices = [
|
||||||
|
recipe.make(amount=5, groups=groups, is_always_shown=True),
|
||||||
|
recipe.make(amount=4, groups=[groups[0]], is_always_shown=True),
|
||||||
|
recipe.make(amount=3, groups=[groups[1]], is_always_shown=False),
|
||||||
|
recipe.make(amount=2, groups=[groups[3]], is_always_shown=False),
|
||||||
|
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
|
||||||
|
]
|
||||||
|
qs = Price.objects.order_by("-amount")
|
||||||
|
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
||||||
|
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
||||||
|
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductClicLimit(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.products = product_recipe.make(
|
||||||
|
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
|
||||||
|
_quantity=6,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
|
||||||
|
|
||||||
|
def test_no_sales_or_basket(self):
|
||||||
|
"""Test that it works if no sales has been made yet"""
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products
|
||||||
|
|
||||||
|
def test_with_sales(self):
|
||||||
|
"""Test that it works when there are existing sales"""
|
||||||
|
sales = sale_recipe.make(
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||||
|
|
||||||
|
def test_with_sales_and_basket(self):
|
||||||
|
"""Test that it works when there are existing sales and basket items."""
|
||||||
|
sales = sale_recipe.make(
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
|
||||||
|
basket = baker.make(
|
||||||
|
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
|
||||||
|
)
|
||||||
|
items = baker.make(
|
||||||
|
BasketItem,
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
basket=basket,
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
)
|
||||||
|
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||||
|
|
||||||
|
# expired basket items shouldn't be accounted when computing clic limit
|
||||||
|
item = BasketItem.objects.filter(product=self.products[1])[0]
|
||||||
|
item.basket = baker.make(
|
||||||
|
Basket,
|
||||||
|
date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
item.save()
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[1:]
|
||||||
|
|||||||
+20
-22
@@ -73,7 +73,7 @@ class CounterClick(
|
|||||||
kwargs["form_kwargs"] = {
|
kwargs["form_kwargs"] = {
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": self.object,
|
"counter": self.object,
|
||||||
"allowed_products": {product.id: product for product in self.products},
|
"allowed_prices": {price.id: price for price in self.prices},
|
||||||
}
|
}
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class CounterClick(
|
|||||||
):
|
):
|
||||||
return redirect(obj) # Redirect to counter
|
return redirect(obj) # Redirect to counter
|
||||||
|
|
||||||
self.products = obj.get_products_for(self.customer)
|
self.prices = list(obj.get_prices_for(self.customer))
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -121,32 +121,31 @@ class CounterClick(
|
|||||||
# This is important because some items have a negative price
|
# This is important because some items have a negative price
|
||||||
# Negative priced items gives money to the customer and should
|
# Negative priced items gives money to the customer and should
|
||||||
# be processed first so that we don't throw a not enough money error
|
# be processed first so that we don't throw a not enough money error
|
||||||
for form in sorted(formset, key=lambda form: form.product.price):
|
for form in sorted(formset, key=lambda form: form.price.amount):
|
||||||
self.request.session["last_basket"].append(
|
self.request.session["last_basket"].append(
|
||||||
f"{form.cleaned_data['quantity']} x {form.product.name}"
|
f"{form.cleaned_data['quantity']} x {form.price.full_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
common_kwargs = {
|
||||||
|
"product": form.price.product,
|
||||||
|
"club_id": form.price.product.club_id,
|
||||||
|
"counter": self.object,
|
||||||
|
"seller": operator,
|
||||||
|
"customer": self.customer,
|
||||||
|
}
|
||||||
Selling(
|
Selling(
|
||||||
label=form.product.name,
|
**common_kwargs,
|
||||||
product=form.product,
|
label=form.price.full_label,
|
||||||
club=form.product.club,
|
unit_price=form.price.amount,
|
||||||
counter=self.object,
|
|
||||||
unit_price=form.product.price,
|
|
||||||
quantity=form.cleaned_data["quantity"]
|
quantity=form.cleaned_data["quantity"]
|
||||||
- form.cleaned_data["bonus_quantity"],
|
- form.cleaned_data["bonus_quantity"],
|
||||||
seller=operator,
|
|
||||||
customer=self.customer,
|
|
||||||
).save()
|
).save()
|
||||||
if form.cleaned_data["bonus_quantity"] > 0:
|
if form.cleaned_data["bonus_quantity"] > 0:
|
||||||
Selling(
|
Selling(
|
||||||
label=f"{form.product.name} (Plateau)",
|
**common_kwargs,
|
||||||
product=form.product,
|
label=f"{form.price.full_label} (Plateau)",
|
||||||
club=form.product.club,
|
|
||||||
counter=self.object,
|
|
||||||
unit_price=0,
|
unit_price=0,
|
||||||
quantity=form.cleaned_data["bonus_quantity"],
|
quantity=form.cleaned_data["bonus_quantity"],
|
||||||
seller=operator,
|
|
||||||
customer=self.customer,
|
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
self.customer.update_returnable_balance()
|
self.customer.update_returnable_balance()
|
||||||
@@ -207,14 +206,13 @@ class CounterClick(
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Add customer to the context."""
|
"""Add customer to the context."""
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["products"] = self.products
|
kwargs["prices"] = self.prices
|
||||||
kwargs["formulas"] = ProductFormula.objects.filter(
|
kwargs["formulas"] = ProductFormula.objects.filter(
|
||||||
result__in=self.products
|
result__in=[p.product_id for p in self.prices]
|
||||||
).prefetch_related("products")
|
).prefetch_related("products")
|
||||||
kwargs["categories"] = defaultdict(list)
|
kwargs["categories"] = defaultdict(list)
|
||||||
for product in kwargs["products"]:
|
for price in self.prices:
|
||||||
if product.product_type:
|
kwargs["categories"][price.product.product_type].append(price)
|
||||||
kwargs["categories"][product.product_type].append(product)
|
|
||||||
kwargs["customer"] = self.customer
|
kwargs["customer"] = self.customer
|
||||||
kwargs["cancel_url"] = self.get_success_url()
|
kwargs["cancel_url"] = self.get_success_url()
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ les fichiers sont à mettre dans un dossier `static/bundled` de l'application à
|
|||||||
Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `bundled/` comme prefix.
|
Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `bundled/` comme prefix.
|
||||||
|
|
||||||
```jinja
|
```jinja
|
||||||
{# Example pour ajouter sith/core/bundled/alpine-index.js #}
|
{# Example pour ajouter sith/core/bundled/alpine-index.ts #}
|
||||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/other-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/other-index.ts') }}"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user