Merge pull request #918 from ae-utbm/taiste

Ajax search input enhancement, promo 25 logo and small improvements
This commit is contained in:
thomas girod 2024-11-12 13:20:53 +01:00 committed by GitHub
commit 0a5ddcea68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1774 additions and 1014 deletions

View File

@ -6,15 +6,9 @@ runs:
- name: Install apt packages - name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@latest uses: awalsh128/cache-apt-pkgs-action@latest
with: with:
packages: gettext packages: gettext pipx
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
- name: Install dependencies
run: |
sudo apt update
sudo apt install gettext
shell: bash
- name: Set up python - name: Set up python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@ -30,7 +24,7 @@ runs:
- name: Install Poetry - name: Install Poetry
if: steps.cached-poetry.outputs.cache-hit != 'true' if: steps.cached-poetry.outputs.cache-hit != 'true'
shell: bash shell: bash
run: curl -sSL https://install.python-poetry.org | python3 - run: pipx install poetry
- name: Check pyproject.toml syntax - name: Check pyproject.toml syntax
shell: bash shell: bash

View File

@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78 uses: appleboy/ssh-action@v1.1.0
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@ -33,8 +33,7 @@ jobs:
# 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: |
export PATH="/home/sith/.local/bin:$PATH" cd ${{secrets.SITH_PATH}}
pushd ${{secrets.SITH_PATH}}
git fetch git fetch
git reset --hard origin/master git reset --hard origin/master
@ -42,7 +41,7 @@ jobs:
npm install npm install
poetry run ./manage.py install_xapian poetry run ./manage.py install_xapian
poetry run ./manage.py migrate poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --clear-generated --noinput poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages poetry run ./manage.py compilemessages
sudo systemctl restart uwsgi sudo systemctl restart uwsgi

View File

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78 uses: appleboy/ssh-action@v1.1.0
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@ -32,8 +32,7 @@ jobs:
# 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: |
export PATH="$HOME/.poetry/bin:$PATH" cd ${{secrets.SITH_PATH}}
pushd ${{secrets.SITH_PATH}}
git fetch git fetch
git reset --hard origin/taiste git reset --hard origin/taiste
@ -41,7 +40,7 @@ jobs:
npm install npm install
poetry run ./manage.py install_xapian poetry run ./manage.py install_xapian
poetry run ./manage.py migrate poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --clear-generated --noinput poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages poetry run ./manage.py compilemessages
sudo systemctl restart uwsgi sudo systemctl restart uwsgi

23
accounting/api.py Normal file
View File

@ -0,0 +1,23 @@
from typing import Annotated
from annotated_types import MinLen
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup])
class AccountingController(ControllerBase):
@route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club_account(self, search: Annotated[str, MinLen(1)]):
return ClubAccount.objects.filter(name__icontains=search).values()
@route.get("/company", response=PaginatedResponseSchema[CompanySchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_company(self, search: Annotated[str, MinLen(1)]):
return Company.objects.filter(name__icontains=search).values()

15
accounting/schemas.py Normal file
View File

@ -0,0 +1,15 @@
from ninja import ModelSchema
from accounting.models import ClubAccount, Company
class ClubAccountSchema(ModelSchema):
class Meta:
model = ClubAccount
fields = ["id", "name"]
class CompanySchema(ModelSchema):
class Meta:
model = Company
fields = ["id", "name"]

View File

@ -0,0 +1,60 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type ClubAccountSchema,
type CompanySchema,
accountingSearchClubAccount,
accountingSearchCompany,
} from "#openapi";
@registerComponent("club-account-ajax-select")
export class ClubAccountAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchClubAccount({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("company-ajax-select")
export class CompanyAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchCompany({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CompanySchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CompanySchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -61,10 +61,10 @@
<script> <script>
$( function() { $( function() {
var target_type = $('#id_target_type'); var target_type = $('#id_target_type');
var user = $('#id_user_wrapper'); var user = $('user-ajax-select');
var club = $('#id_club_wrapper'); var club = $('club-ajax-select');
var club_account = $('#id_club_account_wrapper'); var club_account = $('club-account-ajax-select');
var company = $('#id_company_wrapper'); var company = $('company-ajax-select');
var other = $('#id_target_label'); var other = $('#id_target_label');
var need_link = $('#id_need_link_full'); var need_link = $('#id_need_link_full');
function update_targets () { function update_targets () {

View File

@ -15,7 +15,6 @@
import collections import collections
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -39,6 +38,13 @@ from accounting.models import (
Operation, Operation,
SimplifiedAccountingType, SimplifiedAccountingType,
) )
from accounting.widgets.select import (
AutoCompleteSelectClubAccount,
AutoCompleteSelectCompany,
)
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from core.models import User
from core.views import ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
@ -47,6 +53,7 @@ from core.views import (
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import SelectDate, SelectFile from core.views.forms import SelectDate, SelectFile
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling from counter.models import Counter, Product, Selling
# Main accounting view # Main accounting view
@ -334,12 +341,30 @@ class OperationForm(forms.ModelForm):
"invoice": SelectFile, "invoice": SelectFile,
} }
user = AutoCompleteSelectField("users", help_text=None, required=False) user = forms.ModelChoiceField(
club_account = AutoCompleteSelectField( help_text=None,
"club_accounts", help_text=None, required=False required=False,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
club_account = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClubAccount,
queryset=ClubAccount.objects.all(),
)
club = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClub,
queryset=Club.objects.all(),
)
company = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectCompany,
queryset=Company.objects.all(),
) )
club = AutoCompleteSelectField("clubs", help_text=None, required=False)
company = AutoCompleteSelectField("companies", help_text=None, required=False)
need_link = forms.BooleanField( need_link = forms.BooleanField(
label=_("Link this operation to the target account"), label=_("Link this operation to the target account"),
required=False, required=False,
@ -817,8 +842,12 @@ class LabelDeleteView(CanEditMixin, DeleteView):
class CloseCustomerAccountForm(forms.Form): class CloseCustomerAccountForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", label=_("Refound this account"), help_text=None, required=True label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -0,0 +1,39 @@
from pydantic import TypeAdapter
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
_js = ["webpack/accounting/components/ajax-select-index.ts"]
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectMultipleClubAccount(AutoCompleteSelectMultiple):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectCompany(AutoCompleteSelect):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js
class AutoCompleteSelectMultipleCompany(AutoCompleteSelectMultiple):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js

22
club/api.py Normal file
View File

@ -0,0 +1,22 @@
from typing import Annotated
from annotated_types import MinLen
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club
from club.schemas import ClubSchema
from core.api_permissions import CanAccessLookup
@api_controller("/club")
class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ClubSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values()

View File

@ -22,7 +22,6 @@
# #
# #
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -30,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectMultipleUser
from counter.models import Counter from counter.models import Counter
@ -50,11 +50,12 @@ class MailingForm(forms.Form):
ACTION_NEW_SUBSCRIPTION = 2 ACTION_NEW_SUBSCRIPTION = 2
ACTION_REMOVE_SUBSCRIPTION = 3 ACTION_REMOVE_SUBSCRIPTION = 3
subscription_users = AutoCompleteSelectMultipleField( subscription_users = forms.ModelMultipleChoiceField(
"users",
label=_("Users to add"), label=_("Users to add"),
help_text=_("Search users to add (one or more)."), help_text=_("Search users to add (one or more)."),
required=False, required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
) )
def __init__(self, club_id, user_id, mailings, *args, **kwargs): def __init__(self, club_id, user_id, mailings, *args, **kwargs):
@ -111,12 +112,7 @@ class MailingForm(forms.Form):
"""Convert given users into real users and check their validity.""" """Convert given users into real users and check their validity."""
cleaned_data = super().clean() cleaned_data = super().clean()
users = [] users = []
for user_id in cleaned_data["subscription_users"]: for user in cleaned_data["subscription_users"]:
user = User.objects.filter(id=user_id).first()
if not user:
raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid"
)
if not user.email: if not user.email:
raise forms.ValidationError( raise forms.ValidationError(
_("One of the selected users doesn't have an email address"), _("One of the selected users doesn't have an email address"),
@ -180,11 +176,12 @@ class ClubMemberForm(forms.Form):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
users = AutoCompleteSelectMultipleField( users = forms.ModelMultipleChoiceField(
"users",
label=_("Users to add"), label=_("Users to add"),
help_text=_("Search users to add (one or more)."), help_text=_("Search users to add (one or more)."),
required=False, required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -238,12 +235,7 @@ class ClubMemberForm(forms.Form):
""" """
cleaned_data = super().clean() cleaned_data = super().clean()
users = [] users = []
for user_id in cleaned_data["users"]: for user in cleaned_data["users"]:
user = User.objects.filter(id=user_id).first()
if not user:
raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid"
)
if not user.is_subscribed: if not user.is_subscribed:
raise forms.ValidationError( raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid" _("User must be subscriber to take part to a club"), code="invalid"

9
club/schemas.py Normal file
View File

@ -0,0 +1,9 @@
from ninja import ModelSchema
from club.models import Club
class ClubSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name"]

View File

@ -0,0 +1,30 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
export class ClubAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -254,7 +254,7 @@ class TestClubModel(TestClub):
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{"users": self.subscriber.id, "role": 3}, {"users": [self.subscriber.id], "role": 3},
) )
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
@ -266,7 +266,7 @@ class TestClubModel(TestClub):
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{ {
"users": f"|{self.subscriber.id}|{self.krophil.id}|", "users": (self.subscriber.id, self.krophil.id),
"role": 3, "role": 3,
}, },
) )
@ -330,7 +330,7 @@ class TestClubModel(TestClub):
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{ {
"users": f"|{self.subscriber.id}|{9999}|", "users": (self.subscriber.id, 9999),
"start_date": "12/06/2016", "start_date": "12/06/2016",
"role": 3, "role": 3,
}, },
@ -629,7 +629,7 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|%s|%s|" % (self.comunity.id, self.rbatsbak.id), "subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id, "subscription_mailing": Mailing.objects.get(email="mde").id,
}, },
) )
@ -715,16 +715,17 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|789|", "subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id, "subscription_mailing": Mailing.objects.get(email="mde").id,
}, },
) )
assert response.status_code == 200 assert response.status_code == 200
self.assertInHTML( self.assertInHTML(
_("One of the selected users doesn't exist"), response.content.decode() _("You must specify at least an user or an email address"),
response.content.decode(),
) )
# An user has no email adress # An user has no email address
self.krophil.email = "" self.krophil.email = ""
self.krophil.save() self.krophil.save()
@ -782,8 +783,11 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|%s|%s|%s|" "subscription_users": (
% (self.comunity.id, self.rbatsbak.id, self.krophil.id), self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id, "subscription_mailing": mde.id,
}, },
) )

23
club/widgets/select.py Normal file
View File

@ -0,0 +1,23 @@
from pydantic import TypeAdapter
from club.models import Club
from club.schemas import ClubSchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
_js = ["webpack/club/components/ajax-select-index.ts"]
class AutoCompleteSelectClub(AutoCompleteSelect):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
js = _js
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
js = _js

View File

@ -51,7 +51,8 @@ from core.views import (
QuickNotifMixin, QuickNotifMixin,
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import MarkdownInput, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object

View File

@ -11,11 +11,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot from core.api_permissions import (
from core.models import User CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema, MarkdownSchema,
SithFileSchema,
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
@ -44,7 +49,7 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter]) @api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
@ -62,6 +67,30 @@ class UserController(ControllerBase):
) )
@api_controller("/file")
class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@api_controller("/group")
class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter.""" """Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META["HTTP_REFERER"]: if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False return False
token = request.session.get("counter_token") token = request.session.get("counter_token")
if not token: if not token:
return False return False
return Counter.objects.filter(token=token).exists() return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter

View File

@ -1,141 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from ajax_select import LookupChannel, register
from django.core.exceptions import PermissionDenied
from accounting.models import ClubAccount, Company
from club.models import Club
from core.models import Group, SithFile, User
from core.views.site import search_user
from counter.models import Counter, Customer, Product
from counter.utils import is_logged_in_counter
class RightManagedLookupChannel(LookupChannel):
def check_auth(self, request):
if not request.user.was_subscribed and not is_logged_in_counter(request):
raise PermissionDenied
@register("users")
class UsersLookup(RightManagedLookupChannel):
model = User
def get_query(self, q, request):
return search_user(q)
def format_match(self, obj):
return obj.get_mini_item()
def format_item_display(self, item):
return item.get_display_name()
@register("customers")
class CustomerLookup(RightManagedLookupChannel):
model = Customer
def get_query(self, q, request):
return list(Customer.objects.filter(user__in=search_user(q)))
def format_match(self, obj):
return obj.user.get_mini_item()
def format_item_display(self, obj):
return f"{obj.user.get_display_name()} ({obj.account_id})"
@register("groups")
class GroupsLookup(RightManagedLookupChannel):
model = Group
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_match(self, obj):
return obj.name
def format_item_display(self, item):
return item.name
@register("clubs")
class ClubLookup(RightManagedLookupChannel):
model = Club
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_match(self, obj):
return obj.name
def format_item_display(self, item):
return item.name
@register("counters")
class CountersLookup(RightManagedLookupChannel):
model = Counter
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name
@register("products")
class ProductsLookup(RightManagedLookupChannel):
model = Product
def get_query(self, q, request):
return (
self.model.objects.filter(name__icontains=q)
| self.model.objects.filter(code__icontains=q)
).filter(archived=False)[:50]
def format_item_display(self, item):
return "%s (%s)" % (item.name, item.code)
@register("files")
class SithFileLookup(RightManagedLookupChannel):
model = SithFile
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
@register("club_accounts")
class ClubAccountLookup(RightManagedLookupChannel):
model = ClubAccount
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name
@register("companies")
class CompaniesLookup(RightManagedLookupChannel):
model = Company
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name

View File

@ -1,3 +1,4 @@
from pathlib import Path
from typing import Annotated from typing import Annotated
from annotated_types import MinLen from annotated_types import MinLen
@ -8,7 +9,7 @@ from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from core.models import User from core.models import Group, SithFile, User
class SimpleUserSchema(ModelSchema): class SimpleUserSchema(ModelSchema):
@ -45,6 +46,24 @@ class UserProfileSchema(ModelSchema):
return obj.profile_pict.get_download_url() return obj.profile_pict.get_download_url()
class SithFileSchema(ModelSchema):
class Meta:
model = SithFile
fields = ["id", "name"]
path: str
@staticmethod
def resolve_path(obj: SithFile) -> str:
return str(Path(obj.get_parent_path()) / obj.name)
class GroupSchema(ModelSchema):
class Meta:
model = Group
fields = ["id", "name"]
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] search: Annotated[str, MinLen(1)]
exclude: list[int] | None = Field( exclude: list[int] | None = Field(

View File

@ -0,0 +1,55 @@
/* This also requires ajax-select-index.css */
.ts-dropdown {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
height: 40px;
width: 40px;
object-fit: cover;
border-radius: 50%;
}
}
}
.ts-wrapper {
margin: 5px;
}
.ts-wrapper.single {
width: 263px; // same length as regular text inputs
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa;
}
.ts-wrapper.multi .ts-control {
[data-value],
[data-value].active {
background-image: none;
cursor: pointer;
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
padding-left: 10px;
text-shadow: none;
box-shadow: none;
}
}
.ts-dropdown {
.option.active {
background-color: #e5eafa;
color: inherit;
}
}

BIN
core/static/core/img/promo_25.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -712,63 +712,6 @@ a:not(.button) {
} }
} }
.tomselected {
margin: 10px 0 !important;
max-width: 100%;
min-width: 100%;
ul {
margin: 0;
}
textarea {
background-color: inherit;
}
.select2-container--default {
color: black;
}
}
.ts-dropdown {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
height: 40px;
width: 40px;
object-fit: cover;
border-radius: 50%;
}
}
}
.ts-control {
.item {
.fa-times {
margin-left: 5px;
margin-right: 5px;
}
cursor: pointer;
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
}
}
#news_details { #news_details {
display: inline-block; display: inline-block;
margin-top: 20px; margin-top: 20px;

View File

@ -1,93 +0,0 @@
import "tom-select/dist/css/tom-select.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type { TomItem, TomLoadCallback, TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type UserProfileSchema, userSearchUsers } from "#openapi";
@registerComponent("ajax-select")
export class AjaxSelect extends inheritHtmlElement("select") {
public widget: TomSelect;
public filter?: <T>(items: T[]) => T[];
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => {
this.loadTomSelect();
});
}
loadTomSelect() {
const minCharNumberForSearch = 2;
let maxItems = 1;
if (this.node.multiple) {
maxItems = Number.parseInt(this.node.dataset.max) ?? null;
}
this.widget = new TomSelect(this.node, {
hideSelected: true,
diacritics: true,
duplicates: false,
maxItems: maxItems,
loadThrottle: Number.parseInt(this.node.dataset.delay) ?? null,
valueField: "id",
labelField: "display_name",
searchField: ["display_name", "nick_name", "first_name", "last_name"],
placeholder: this.node.dataset.placeholder ?? "",
shouldLoad: (query: string) => {
return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
load: (query: string, callback: TomLoadCallback) => {
userSearchUsers({
query: {
search: query,
},
}).then((response) => {
if (response.data) {
if (this.filter) {
callback(this.filter(response.data.results), []);
} else {
callback(response.data.results, []);
}
return;
}
callback([], []);
});
},
render: {
option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
},
item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
});
// Allow removing selected items by clicking on them
this.widget.on("item_select", (item: TomItem) => {
this.widget.removeItem(item);
});
// Remove typed text once an item has been selected
this.widget.on("item_add", () => {
this.widget.setTextboxValue("");
});
}
}

View File

@ -0,0 +1,183 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
this.placeholder = newValue ?? "";
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {
return;
}
}
}
connectedCallback() {
super.connectedCallback();
this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors();
}
protected shouldLoad(query: string) {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
plugins: {
// biome-ignore lint/style/useNamingConvention: this is required by the api
remove_button: {
title: gettext("Remove"),
},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
option: (item: TomOption, sanitize: typeof escape_html) => {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.text)}</span>
</div>`;
},
item: (item: TomOption, sanitize: typeof escape_html) => {
return `<span>${sanitize(item.text)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
};
}
protected attachBehaviors() {
/* Called once the widget has been initialized */
}
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
protected abstract valueField: string;
protected abstract labelField: string;
protected abstract searchField: string[];
protected abstract renderOption(
item: TomOption,
sanitize: typeof escape_html,
): string;
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
protected abstract search(query: string): Promise<TomOption[]>;
private initialValues: TomOption[] = [];
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
this.filter = filter;
}
protected shouldLoad(query: string) {
const resp = super.shouldLoad(query);
/* Force order sync with backend if no client side filtering is set */
if (!resp && this.searchField.length === 0) {
this.widget.clearOptions();
}
return resp;
}
protected async loadFunction(query: string, callback: TomLoadCallback) {
/* Force order sync with backend if no client side filtering is set */
if (this.searchField.length === 0) {
this.widget.clearOptions();
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);
} else {
callback(resp, []);
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
...super.tomSelectSettings(),
hideSelected: true,
diacritics: true,
duplicates: false,
valueField: this.valueField,
labelField: this.labelField,
searchField: this.searchField,
load: (query: string, callback: TomLoadCallback) =>
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
...super.tomSelectSettings().render,
option: this.renderOption,
item: this.renderItem,
},
};
}
connectedCallback() {
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
const initial = this.querySelector("slot[name='initial']")?.textContent;
this.initialValues = initial ? JSON.parse(initial) : [];
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}

View File

@ -0,0 +1,100 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
@registerComponent("user-ajax-select")
export class UserAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "display_name";
protected searchField: string[] = []; // Disable local search filter and rely on tested backend
protected async search(query: string): Promise<TomOption[]> {
const resp = await userSearchUsers({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
}
protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.display_name)}</span>`;
}
}
@registerComponent("group-ajax-select")
export class GroupsAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await groupSearchGroup({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: GroupSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: GroupSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("sith-file-ajax-select")
export class SithFileAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await sithfileSearchFiles({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: SithFileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: SithFileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -13,8 +13,13 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => { previewRender: (plainText: string, preview: MarkdownInput) => {
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => { /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = ( preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } }) await markdownRenderMarkdown({ body: { text: plainText } })
).data as string; ).data as string;
@ -22,7 +27,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}; };
func(plainText, preview); func(plainText, preview);
return null; return null;
}, 300), }, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
toolbar: [ toolbar: [
{ {
@ -185,8 +191,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
@registerComponent("markdown-input") @registerComponent("markdown-input")
class MarkdownInput extends inheritHtmlElement("textarea") { class MarkdownInput extends inheritHtmlElement("textarea") {
constructor() { connectedCallback() {
super(); super.connectedCallback();
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node)); loadEasyMde(this.node);
} }
} }

View File

@ -0,0 +1,33 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("link-once")
export class LinkOnce extends inheritHtmlElement("link") {
connectedCallback() {
super.connectedCallback(false);
// We get href from node.attributes instead of node.href to avoid getting the domain part
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
}
}
/**
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
connectedCallback() {
super.connectedCallback(false);
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
}
}

View File

@ -1,14 +1,14 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import { client } from "#openapi"; import { client } from "#openapi";
interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
count: number; count: number;
next: string | null; next: string | null;
previous: string | null; previous: string | null;
results: T[]; results: T[];
} }
interface PaginatedRequest { export interface PaginatedRequest {
query?: { query?: {
page?: number; page?: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case

View File

@ -30,8 +30,7 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
return class Inherited extends HTMLElement { return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K]; protected node: HTMLElementTagNameMap[K];
constructor() { connectedCallback(autoAddNode?: boolean) {
super();
this.node = document.createElement(tagName); this.node = document.createElement(tagName);
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) {
@ -44,7 +43,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
this.removeAttributeNode(attr); this.removeAttributeNode(attr);
this.node.setAttributeNode(attr); this.node.setAttributeNode(attr);
} }
this.node.innerHTML = this.innerHTML;
this.innerHTML = "";
// Automatically add node to DOM if autoAddNode is true or unspecified
if (autoAddNode === undefined || autoAddNode) {
this.appendChild(this.node); this.appendChild(this.node);
} }
}
}; };
} }

View File

@ -7,7 +7,6 @@
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}"> <link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
@ -21,6 +20,8 @@
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
<script src="{{ static('webpack/alpine-index.js') }}" defer></script> <script src="{{ static('webpack/alpine-index.js') }}" defer></script>
<!-- Jquery declared here to be accessible in every django widgets --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script> <script src="{{ static('webpack/jquery-index.js') }}"></script>
@ -301,8 +302,6 @@
{% endif %} {% endif %}
{% block script %} {% block script %}
<script src="{{ static('ajax_select/js/ajax_select.js') }}"></script>
<script src="{{ url('javascript-catalog') }}"></script>
<script> <script>
function showMenu() { function showMenu() {
let navbar = document.getElementById("navbar-content"); let navbar = document.getElementById("navbar-content");

View File

@ -33,7 +33,6 @@
{% endif %} {% endif %}
{% csrf_token %} {% csrf_token %}
{% render_honeypot_field %}
<div> <div>
<label for="{{ form.username.name }}">{{ form.username.label }}</label> <label for="{{ form.username.name }}">{{ form.username.label }}</label>

View File

@ -0,0 +1,23 @@
{% for js in statics.js %}
<script-once src="{{ js }}" defer></script-once>
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %}
<optgroup label="{{ group_name }}">
{% endif %}
{% for widget in group_choices %}
{% include widget.template_name %}
{% endfor %}
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
{% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %}
</{{ component }}>

View File

@ -1,7 +1,7 @@
<div> <div>
<script-once src="{{ statics.js }}" defer></script-once>
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input> <markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
{# The easymde script can be included twice, it's safe in the code #} </div>
<script src="{{ statics.js }}" defer> </script>
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
</div>

View File

@ -146,39 +146,20 @@ class TestUserLogin:
"""Should not login a user correctly.""" """Should not login a user correctly."""
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{ {"username": user.username, "password": "wrong-password"},
"username": user.username,
"password": "wrong-password",
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE,
},
) )
assert response.status_code == 200 assert response.status_code == 200
assert ( assert (
'<p class="alert alert-red">Votre nom d\'utilisateur ' '<p class="alert alert-red">Votre nom d\'utilisateur '
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>" "et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) in str(response.content.decode()) ) in str(response.content.decode())
def test_login_honeypot(self, client, user):
response = client.post(
reverse("core:login"),
{
"username": user.username,
"password": "wrong-password",
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE + "incorrect",
},
)
assert response.status_code == 200
assert response.wsgi_request.user.is_anonymous assert response.wsgi_request.user.is_anonymous
def test_login_success(self, client, user): def test_login_success(self, client, user):
"""Should login a user correctly.""" """Should login a user correctly."""
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{ {"username": user.username, "password": "plop"},
"username": user.username,
"password": "plop",
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE,
},
) )
assertRedirects(response, reverse("core:index")) assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user assert response.wsgi_request.user == user

View File

@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from ajax_select import make_ajax_field
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -39,6 +38,11 @@ from core.views import (
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile,
AutoCompleteSelectUser,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm):
class Meta: class Meta:
model = SithFile model = SithFile
fields = ["parent", "owner", "edit_groups", "view_groups"] fields = ["parent", "owner", "edit_groups", "view_groups"]
widgets = {
"parent": AutoCompleteSelectSithFile,
"owner": AutoCompleteSelectUser,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
}
parent = make_ajax_field(SithFile, "parent", "files", help_text="")
edit_groups = make_ajax_field(
SithFile, "edit_groups", "groups", help_text="", label=_("edit group")
)
view_groups = make_ajax_field(
SithFile, "view_groups", "groups", help_text="", label=_("view group")
)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@ -23,20 +23,16 @@
import re import re
from io import BytesIO from io import BytesIO
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.forms import ( from django.forms import (
CheckboxSelectMultiple, CheckboxSelectMultiple,
DateInput, DateInput,
DateTimeInput, DateTimeInput,
Textarea,
TextInput, TextInput,
) )
from django.utils.translation import gettext from django.utils.translation import gettext
@ -47,6 +43,12 @@ from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, SithFile, User from core.models import Gift, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
# Widgets # Widgets
@ -65,19 +67,6 @@ class SelectDate(DateInput):
super().__init__(attrs=attrs, format=format or "%Y-%m-%d") super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
class MarkdownInput(Textarea):
template_name = "core/widgets/markdown_textarea.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/easymde-index.css"),
}
return context
class NFCTextInput(TextInput): class NFCTextInput(TextInput):
template_name = "core/widgets/nfc.jinja" template_name = "core/widgets/nfc.jinja"
@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form):
], ],
label=_("Add"), label=_("Add"),
) )
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", required=True, label=_("Select user"), help_text="" label=_("Select user"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
def __init__(self, *args, user: User, **kwargs): def __init__(self, *args, user: User, **kwargs):
@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm):
class Meta: class Meta:
model = Page model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
edit_groups = make_ajax_field( "parent": AutoCompleteSelect,
Page, "edit_groups", "groups", help_text="", label=_("edit groups") "owner_group": AutoCompleteSelectGroup,
) "edit_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "view_groups": AutoCompleteSelectMultipleGroup,
Page, "view_groups", "groups", help_text="", label=_("view groups") }
)
def __init__(self, *arg, **kwargs): def __init__(self, *arg, **kwargs):
super().__init__(*arg, **kwargs) super().__init__(*arg, **kwargs)
@ -372,13 +364,12 @@ class PageForm(forms.ModelForm):
class Meta: class Meta:
model = Page model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
edit_groups = make_ajax_field( "parent": AutoCompleteSelect,
Page, "edit_groups", "groups", help_text="", label=_("edit groups") "owner_group": AutoCompleteSelectGroup,
) "edit_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "view_groups": AutoCompleteSelectMultipleGroup,
Page, "view_groups", "groups", help_text="", label=_("view groups") }
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -15,7 +15,6 @@
"""Views to manage Groups.""" """Views to manage Groups."""
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -24,6 +23,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User from core.models import RealGroup, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import (
AutoCompleteSelectMultipleUser,
)
# Forms # Forms
@ -34,6 +36,15 @@ class EditMembersForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.current_users = kwargs.pop("users", []) self.current_users = kwargs.pop("users", [])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["users_added"] = forms.ModelMultipleChoiceField(
label=_("Users to add to group"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.exclude(id__in=self.current_users).all(),
)
self.fields["users_removed"] = forms.ModelMultipleChoiceField( self.fields["users_removed"] = forms.ModelMultipleChoiceField(
User.objects.filter(id__in=self.current_users).all(), User.objects.filter(id__in=self.current_users).all(),
label=_("Users to remove from group"), label=_("Users to remove from group"),
@ -41,31 +52,6 @@ class EditMembersForm(forms.Form):
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
) )
users_added = AutoCompleteSelectMultipleField(
"users",
label=_("Users to add to group"),
help_text=_("Search users to add (one or more)."),
required=False,
)
def clean_users_added(self):
"""Check that the user is not trying to add an user already in the group."""
cleaned_data = super().clean()
users_added = cleaned_data.get("users_added", None)
if not users_added:
return users_added
current_users = [
str(id_) for id_ in self.current_users.values_list("id", flat=True)
]
for user in users_added:
if user in current_users:
raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid"
)
return users_added
# Views # Views
@ -110,8 +96,10 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
data = form.clean() data = form.clean()
group = self.get_object() group = self.get_object()
if data["users_removed"]:
for user in data["users_removed"]: for user in data["users_removed"]:
group.users.remove(user) group.users.remove(user)
if data["users_added"]:
for user in data["users_added"]: for user in data["users_added"]:
group.users.add(user) group.users.add(user)
group.save() group.save()

View File

@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import LockError, Page, PageRev from core.models import LockError, Page, PageRev
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from core.views.forms import MarkdownInput, PageForm, PagePropForm from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput
class CanEditPagePropMixin(CanEditPropMixin): class CanEditPagePropMixin(CanEditPropMixin):

View File

@ -77,7 +77,6 @@ from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@method_decorator(check_honeypot, name="post")
class SithLoginView(views.LoginView): class SithLoginView(views.LoginView):
"""The login View.""" """The login View."""

View File

@ -0,0 +1,15 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.forms import Textarea
class MarkdownInput(Textarea):
template_name = "core/widgets/markdown_textarea.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/core/components/easymde-index.css"),
}
return context

View File

@ -0,0 +1,111 @@
from collections.abc import Collection
from typing import Any
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter
from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
class AutoCompleteSelectMixin:
component_name = "autocomplete-select"
template_name = "core/widgets/autocomplete_select.jinja"
model: type[Model] | None = None
adapter: TypeAdapter[Collection[ModelSchema]] | None = None
pk = "id"
js = [
"webpack/core/components/ajax-select-index.ts",
]
css = [
"webpack/core/components/ajax-select-index.css",
"core/components/ajax-select.scss",
]
def get_queryset(self, pks: Collection[Any]) -> QuerySet:
return self.model.objects.filter(
**{
f"{self.pk}__in": [
pk
for pk in pks
if str(pk).isdigit() # We filter empty values for create views
]
}
).all()
def __init__(self, attrs=None, choices=()):
if self.is_ajax:
choices = () # Avoid computing anything when in ajax mode
super().__init__(attrs=attrs, choices=choices)
@property
def is_ajax(self):
return self.adapter and self.model
def optgroups(self, name, value, attrs=None):
"""Don't create option groups when doing ajax"""
if self.is_ajax:
return []
return super().optgroups(name, value, attrs=attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["widget"]["attrs"]["autocomplete"] = "off"
context["component"] = self.component_name
context["statics"] = {
"js": [staticfiles_storage.url(file) for file in self.js],
"css": [staticfiles_storage.url(file) for file in self.css],
}
if self.is_ajax:
context["initial"] = self.adapter.dump_json(
self.adapter.validate_python(
self.get_queryset(context["widget"]["value"])
)
).decode("utf-8")
return context
class AutoCompleteSelect(AutoCompleteSelectMixin, Select): ...
class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ...
class AutoCompleteSelectUser(AutoCompleteSelect):
component_name = "user-ajax-select"
model = User
adapter = TypeAdapter(list[UserProfileSchema])
class AutoCompleteSelectMultipleUser(AutoCompleteSelectMultiple):
component_name = "user-ajax-select"
model = User
adapter = TypeAdapter(list[UserProfileSchema])
class AutoCompleteSelectGroup(AutoCompleteSelect):
component_name = "group-ajax-select"
model = Group
adapter = TypeAdapter(list[GroupSchema])
class AutoCompleteSelectMultipleGroup(AutoCompleteSelectMultiple):
component_name = "group-ajax-select"
model = Group
adapter = TypeAdapter(list[GroupSchema])
class AutoCompleteSelectSithFile(AutoCompleteSelect):
component_name = "sith-file-ajax-select"
model = SithFile
adapter = TypeAdapter(list[SithFileSchema])
class AutoCompleteSelectMultipleSithFile(AutoCompleteSelectMultiple):
component_name = "sith-file-ajax-select"
model = SithFile
adapter = TypeAdapter(list[SithFileSchema])

View File

@ -12,11 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from ninja_extra import ControllerBase, api_controller, route from typing import Annotated
from core.api_permissions import CanView, IsRoot from annotated_types import MinLen
from counter.models import Counter from django.db.models import Q
from counter.schemas import CounterSchema from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Product
from counter.schemas import (
CounterFilterSchema,
CounterSchema,
ProductSchema,
SimplifiedCounterSchema,
)
@api_controller("/counter") @api_controller("/counter")
@ -37,3 +49,30 @@ class CounterController(ControllerBase):
for c in counters: for c in counters:
self.check_object_permissions(c) self.check_object_permissions(c)
return counters return counters
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_counter(self, filters: Query[CounterFilterSchema]):
return filters.filter(Counter.objects.all())
@api_controller("/product")
class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ProductSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_products(self, search: Annotated[str, MinLen(1)]):
return (
Product.objects.filter(
Q(name__icontains=search) | Q(code__icontains=search)
)
.filter(archived=False)
.values()
)

View File

@ -1,10 +1,15 @@
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.widgets.select import AutoCompleteSelectClub
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
@ -14,6 +19,11 @@ from counter.models import (
Refilling, Refilling,
StudentCard, StudentCard,
) )
from counter.widgets.select import (
AutoCompleteSelectMultipleCounter,
AutoCompleteSelectMultipleProduct,
AutoCompleteSelectProduct,
)
class BillingInfoForm(forms.ModelForm): class BillingInfoForm(forms.ModelForm):
@ -68,8 +78,11 @@ class GetUserForm(forms.Form):
required=False, required=False,
widget=NFCTextInput, widget=NFCTextInput,
) )
id = AutoCompleteSelectField( id = forms.CharField(
"users", required=False, label=_("Select user"), help_text=None label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self): def as_p(self):
@ -81,9 +94,13 @@ class GetUserForm(forms.Form):
cus = None cus = None
if cleaned_data["code"] != "": if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE: if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = StudentCard.objects.filter(uid=cleaned_data["code"]) card = (
if card.exists(): StudentCard.objects.filter(uid=cleaned_data["code"])
cus = card.first().customer .select_related("customer")
.first()
)
if card is not None:
cus = card.customer
if cus is None: if cus is None:
cus = Customer.objects.filter( cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"] account_id__iexact=cleaned_data["code"]
@ -122,8 +139,10 @@ class CounterEditForm(forms.ModelForm):
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="") widgets = {
products = make_ajax_field(Counter, "products", "products", help_text="") "sellers": AutoCompleteSelectMultipleUser,
"products": AutoCompleteSelectMultipleProduct,
}
class ProductEditForm(forms.ModelForm): class ProductEditForm(forms.ModelForm):
@ -145,44 +164,37 @@ class ProductEditForm(forms.ModelForm):
"tray", "tray",
"archived", "archived",
] ]
widgets = {
"parent_product": AutoCompleteSelectMultipleProduct,
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
}
parent_product = AutoCompleteSelectField( counters = forms.ModelMultipleChoiceField(
"products", show_help_text=False, label=_("Parent product"), required=False help_text=None,
)
buying_groups = AutoCompleteSelectMultipleField(
"groups",
show_help_text=False,
help_text="",
label=_("Buying groups"),
required=True,
)
club = AutoCompleteSelectField("clubs", show_help_text=False)
counters = AutoCompleteSelectMultipleField(
"counters",
show_help_text=False,
help_text="",
label=_("Counters"), label=_("Counters"),
required=False, required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = [ self.fields["counters"].initial = self.instance.counters.all()
str(c.id) for c in self.instance.counters.all()
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
if self.fields["counters"].initial: if self.fields["counters"].initial:
for cid in self.fields["counters"].initial: # Remove the product from all counter it was added to
c = Counter.objects.filter(id=int(cid)).first() # It will then only be added to selected counters
c.products.remove(self.instance) for counter in self.fields["counters"].initial:
c.save() counter.products.remove(self.instance)
for cid in self.cleaned_data["counters"]: counter.save()
c = Counter.objects.filter(id=int(cid)).first() for counter in self.cleaned_data["counters"]:
c.products.add(self.instance) counter.products.add(self.instance)
c.save() counter.save()
return ret return ret
@ -199,8 +211,7 @@ class EticketForm(forms.ModelForm):
class Meta: class Meta:
model = Eticket model = Eticket
fields = ["product", "banner", "event_title", "event_date"] fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate} widgets = {
"product": AutoCompleteSelectProduct,
product = AutoCompleteSelectField( "event_date": SelectDate,
"products", show_help_text=False, label=_("Product"), required=True }
)

View File

@ -1,4 +1,5 @@
import logging import logging
import time
from smtplib import SMTPException from smtplib import SMTPException
from django.conf import settings from django.conf import settings
@ -25,9 +26,34 @@ class Command(BaseCommand):
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Do not send the mails, just display the number of users concerned",
)
parser.add_argument(
"-d",
"--delay",
type=float,
default=0,
help="Delay in seconds between each mail sent",
)
def handle(self, *args, **options): def handle(self, *args, **options):
users = list(self._get_users()) users: list[User] = list(self._get_users())
self.stdout.write(f"{len(users)} users will be warned of their account dump") self.stdout.write(f"{len(users)} users will be warned of their account dump")
if options["verbosity"] > 1:
self.stdout.write("Users concerned:\n")
self.stdout.write(
"\n".join(
f" - {user.get_display_name()} ({user.email}) : "
f"{user.customer.amount}"
for user in users
)
)
if options["dry_run"]:
return
dumps = [] dumps = []
for user in users: for user in users:
is_success = self._send_mail(user) is_success = self._send_mail(user)
@ -38,6 +64,10 @@ class Command(BaseCommand):
warning_mail_error=not is_success, warning_mail_error=not is_success,
) )
) )
if options["delay"]:
# avoid spamming the mail server too much
time.sleep(options["delay"])
AccountDump.objects.bulk_create(dumps) AccountDump.objects.bulk_create(dumps)
self.stdout.write("Finished !") self.stdout.write("Finished !")

View File

@ -1,7 +1,10 @@
from ninja import ModelSchema from typing import Annotated
from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema from core.schemas import SimpleUserSchema
from counter.models import Counter from counter.models import Counter, Product
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@ -11,3 +14,19 @@ class CounterSchema(ModelSchema):
class Meta: class Meta:
model = Counter model = Counter
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type", "club", "products"]
class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")
class SimplifiedCounterSchema(ModelSchema):
class Meta:
model = Counter
fields = ["id", "name"]
class ProductSchema(ModelSchema):
class Meta:
model = Product
fields = ["id", "name", "code"]

View File

@ -0,0 +1,60 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductSchema,
counterSearchCounter,
productSearchProducts,
} from "#openapi";
@registerComponent("product-ajax-select")
export class ProductAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await productSearchProducts({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await counterSearchCounter({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CounterSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CounterSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -1,43 +1,40 @@
<p> {% trans %}Hello{% endtrans %},
Bonjour,
</p>
<p> {% trans trimmed date=last_subscription_date|date(DATETIME_FORMAT) -%}
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
You received this email because your last subscription to the You received this email because your last subscription to the
Students' association ended on {{ date }}. Students' association ended on {{ date }}.
{%- endtrans -%} {%- endtrans %}
</p>
<p> {% trans trimmed date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
{%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
In accordance with the Internal Regulations, the balance of any In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back inactive AE account for more than 2 years automatically goes back
to the AE. to the AE.
The money present on your account will therefore be recovered in full The money present on your account will therefore be recovered in full
on {{ date }}, for a total of {{ amount }} €. on {{ date }}, for a total of {{ amount }} €.
{%- endtrans -%} {%- endtrans %}
</p>
<p> {% trans trimmed -%}
{%- trans -%}However, if your subscription is renewed by this date, However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.{%- endtrans -%} your right to keep the money in your AE account will be renewed.
</p> {%- endtrans %}
{% if balance >= 10 %} {% if balance >= 10 -%}
<p> {% trans trimmed -%}
{%- trans -%}You can also request a refund by sending an email to You can also request a refund by sending an email to ae@utbm.fr
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a> before the aforementioned date.
before the aforementioned date.{%- endtrans -%} {%- endtrans %}
</p> {%- endif %}
{% endif %}
<p> {% trans trimmed -%}
{% trans %}Sincerely{% endtrans %}, Whatever you decide, you won't be expelled from the association,
</p> and you won't lose your rights.
You will always be able to renew your subscription later.
If you don't renew your subscription, there will be no consequences
other than the loss of the money currently in your AE account.
{%- endtrans %}
<p> {% trans %}Sincerely{% endtrans %},
L'association des étudiants de l'UTBM <br>
6, Boulevard Anatole France <br> L'association des étudiants de l'UTBM
90000 Belfort 6, Boulevard Anatole France
</p> 90000 Belfort

View File

@ -13,7 +13,7 @@
<h4>{{ product_type or _("Uncategorized") }}</h4> <h4>{{ product_type or _("Uncategorized") }}</h4>
<ul> <ul>
{%- for product in products -%} {%- for product in products -%}
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product }} ({{ product.code }})</a></li> <li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
{%- endfor -%} {%- endfor -%}
</ul> </ul>
{%- else -%} {%- else -%}

View File

@ -17,7 +17,7 @@ import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from http import HTTPStatus from http import HTTPStatus
from operator import attrgetter from operator import itemgetter
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import parse_qs from urllib.parse import parse_qs
@ -801,7 +801,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
model = Product model = Product
queryset = Product.objects.annotate(type_name=F("product_type__name")) queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja" template_name = "counter/product_list.jinja"
ordering = [ ordering = [
F("product_type__priority").desc(nulls_last=True), F("product_type__priority").desc(nulls_last=True),
@ -812,7 +812,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
res = super().get_context_data(**kwargs) res = super().get_context_data(**kwargs)
res["object_list"] = itertools.groupby( res["object_list"] = itertools.groupby(
res["object_list"], key=attrgetter("type_name") res["object_list"], key=itemgetter("product_type__name")
) )
return res return res

35
counter/widgets/select.py Normal file
View File

@ -0,0 +1,35 @@
from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product
from counter.schemas import ProductSchema, SimplifiedCounterSchema
_js = ["webpack/counter/components/ajax-select-index.ts"]
class AutoCompleteSelectCounter(AutoCompleteSelect):
component_name = "counter-ajax-select"
model = Counter
adapter = TypeAdapter(list[SimplifiedCounterSchema])
js = _js
class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple):
component_name = "counter-ajax-select"
model = Counter
adapter = TypeAdapter(list[SimplifiedCounterSchema])
js = _js
class AutoCompleteSelectProduct(AutoCompleteSelect):
component_name = "product-ajax-select"
model = Product
adapter = TypeAdapter(list[ProductSchema])
js = _js
class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
component_name = "product-ajax-select"
model = Product
adapter = TypeAdapter(list[ProductSchema])
js = _js

View File

@ -1,4 +1,4 @@
gq## Objectifs ## Objectifs
Le but de ce projet est de fournir à Le but de ce projet est de fournir à
l'Association des Étudiants de l'UTBM l'Association des Étudiants de l'UTBM

View File

@ -62,5 +62,5 @@ Le post processing est géré par le module `staticfiles`. Les fichiers sont
compilés à la volée en mode développement. compilés à la volée en mode développement.
Pour la production, ils sont compilés uniquement lors du `./manage.py collectstatic`. Pour la production, ils sont compilés uniquement lors du `./manage.py collectstatic`.
Les fichiers générés sont ajoutés dans le dossier `sith/generated`. Celui-ci est Les fichiers générés sont ajoutés dans le dossier `staticfiles/generated`. Celui-ci est
ensuite enregistré comme dossier supplémentaire à collecter dans Django. ensuite enregistré comme dossier supplémentaire à collecter dans Django.

View File

@ -1,7 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
@ -13,7 +11,13 @@ from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import MarkdownInput, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING: if TYPE_CHECKING:
@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm):
class Meta: class Meta:
model = Candidature model = Candidature
fields = ["user", "role", "program", "election_list"] fields = ["user", "role", "program", "election_list"]
widgets = {"program": MarkdownInput} labels = {
"user": _("User to candidate"),
user = AutoCompleteSelectField( }
"users", label=_("User to candidate"), help_text=None, required=True widgets = {
) "program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm):
class Meta: class Meta:
model = Role model = Role
fields = ["title", "election", "description", "max_choice"] fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm):
class Meta: class Meta:
model = ElectionList model = ElectionList
fields = ("title", "election") fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm):
"vote_groups", "vote_groups",
"candidature_groups", "candidature_groups",
] ]
widgets = {
edit_groups = make_ajax_field( "edit_groups": AutoCompleteSelectMultipleGroup,
Election, "edit_groups", "groups", help_text="", label=_("edit groups") "view_groups": AutoCompleteSelectMultipleGroup,
) "vote_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "candidature_groups": AutoCompleteSelectMultipleGroup,
Election, "view_groups", "groups", help_text="", label=_("view groups") }
)
vote_groups = make_ajax_field(
Election, "vote_groups", "groups", help_text="", label=_("vote groups")
)
candidature_groups = make_ajax_field(
Election,
"candidature_groups",
"groups",
help_text="",
label=_("candidature groups"),
)
start_date = forms.DateTimeField( start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True label=_("Start date"), widget=SelectDateTime, required=True
@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = Election.objects.get(id=self.election.id) obj.election = Election.objects.get(id=self.election.id)
obj.user = obj.user if hasattr(obj, "user") else self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):

View File

@ -25,7 +25,6 @@ import logging
import math import math
from functools import partial from functools import partial
from ajax_select import make_ajax_field
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -43,6 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from haystack.query import RelatedSearchQuerySet from haystack.query import RelatedSearchQuerySet
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub
from core.views import ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
@ -50,7 +50,11 @@ from core.views import (
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.views.forms import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
)
from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic
@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm):
"edit_groups", "edit_groups",
"view_groups", "view_groups",
] ]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"owner_club": AutoCompleteSelectClub,
}
edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="") parent = ForumNameField(
view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="") Forum.objects.all(), widget=AutoCompleteSelect, required=False
parent = ForumNameField(Forum.objects.all()) )
class ForumCreateView(CanCreateMixin, CreateView): class ForumCreateView(CanCreateMixin, CreateView):

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-16 02:19+0200\n" "POT-Creation-Date: 2024-11-10 16:00+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -22,87 +22,94 @@ msgstr ""
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: core/static/webpack/ajax-select-index.ts:73 #: core/static/webpack/core/components/ajax-select-base.ts:68
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:57
msgid "Remove"
msgstr "Retirer"
#: core/static/webpack/core/components/ajax-select-base.ts:88
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:77
msgid "You need to type %(number)s more characters" msgid "You need to type %(number)s more characters"
msgstr "Vous devez taper %(number)s caractères de plus" msgstr "Vous devez taper %(number)s caractères de plus"
#: core/static/webpack/ajax-select-index.ts:76 #: core/static/webpack/core/components/ajax-select-base.ts:92
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:81
msgid "No results found" msgid "No results found"
msgstr "Aucun résultat trouvé" msgstr "Aucun résultat trouvé"
#: core/static/webpack/easymde-index.ts:31 #: core/static/webpack/core/components/easymde-index.ts:38
msgid "Heading" msgid "Heading"
msgstr "Titre" msgstr "Titre"
#: core/static/webpack/easymde-index.ts:37 #: core/static/webpack/core/components/easymde-index.ts:44
msgid "Italic" msgid "Italic"
msgstr "Italique" msgstr "Italique"
#: core/static/webpack/easymde-index.ts:43 #: core/static/webpack/core/components/easymde-index.ts:50
msgid "Bold" msgid "Bold"
msgstr "Gras" msgstr "Gras"
#: core/static/webpack/easymde-index.ts:49 #: core/static/webpack/core/components/easymde-index.ts:56
msgid "Strikethrough" msgid "Strikethrough"
msgstr "Barré" msgstr "Barré"
#: core/static/webpack/easymde-index.ts:58 #: core/static/webpack/core/components/easymde-index.ts:65
msgid "Underline" msgid "Underline"
msgstr "Souligné" msgstr "Souligné"
#: core/static/webpack/easymde-index.ts:67 #: core/static/webpack/core/components/easymde-index.ts:74
msgid "Superscript" msgid "Superscript"
msgstr "Exposant" msgstr "Exposant"
#: core/static/webpack/easymde-index.ts:76 #: core/static/webpack/core/components/easymde-index.ts:83
msgid "Subscript" msgid "Subscript"
msgstr "Indice" msgstr "Indice"
#: core/static/webpack/easymde-index.ts:82 #: core/static/webpack/core/components/easymde-index.ts:89
msgid "Code" msgid "Code"
msgstr "Code" msgstr "Code"
#: core/static/webpack/easymde-index.ts:89 #: core/static/webpack/core/components/easymde-index.ts:96
msgid "Quote" msgid "Quote"
msgstr "Citation" msgstr "Citation"
#: core/static/webpack/easymde-index.ts:95 #: core/static/webpack/core/components/easymde-index.ts:102
msgid "Unordered list" msgid "Unordered list"
msgstr "Liste non ordonnée" msgstr "Liste non ordonnée"
#: core/static/webpack/easymde-index.ts:101 #: core/static/webpack/core/components/easymde-index.ts:108
msgid "Ordered list" msgid "Ordered list"
msgstr "Liste ordonnée" msgstr "Liste ordonnée"
#: core/static/webpack/easymde-index.ts:108 #: core/static/webpack/core/components/easymde-index.ts:115
msgid "Insert link" msgid "Insert link"
msgstr "Insérer lien" msgstr "Insérer lien"
#: core/static/webpack/easymde-index.ts:114 #: core/static/webpack/core/components/easymde-index.ts:121
msgid "Insert image" msgid "Insert image"
msgstr "Insérer image" msgstr "Insérer image"
#: core/static/webpack/easymde-index.ts:120 #: core/static/webpack/core/components/easymde-index.ts:127
msgid "Insert table" msgid "Insert table"
msgstr "Insérer tableau" msgstr "Insérer tableau"
#: core/static/webpack/easymde-index.ts:127 #: core/static/webpack/core/components/easymde-index.ts:134
msgid "Clean block" msgid "Clean block"
msgstr "Nettoyer bloc" msgstr "Nettoyer bloc"
#: core/static/webpack/easymde-index.ts:134 #: core/static/webpack/core/components/easymde-index.ts:141
msgid "Toggle preview" msgid "Toggle preview"
msgstr "Activer la prévisualisation" msgstr "Activer la prévisualisation"
#: core/static/webpack/easymde-index.ts:140 #: core/static/webpack/core/components/easymde-index.ts:147
msgid "Toggle side by side" msgid "Toggle side by side"
msgstr "Activer la vue côte à côte" msgstr "Activer la vue côte à côte"
#: core/static/webpack/easymde-index.ts:146 #: core/static/webpack/core/components/easymde-index.ts:153
msgid "Toggle fullscreen" msgid "Toggle fullscreen"
msgstr "Activer le plein écran" msgstr "Activer le plein écran"
#: core/static/webpack/easymde-index.ts:153 #: core/static/webpack/core/components/easymde-index.ts:160
msgid "Markdown guide" msgid "Markdown guide"
msgstr "Guide markdown" msgstr "Guide markdown"
@ -119,9 +126,11 @@ msgid "Incorrect value"
msgstr "Valeur incorrecte" msgstr "Valeur incorrecte"
#: sas/static/webpack/sas/viewer-index.ts:271 #: sas/static/webpack/sas/viewer-index.ts:271
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:234
msgid "Couldn't moderate picture" msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image" msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/webpack/sas/viewer-index.ts:284 #: sas/static/webpack/sas/viewer-index.ts:284
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:248
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" msgstr "Il n'a pas été possible de supprimer l'image"

View File

@ -25,7 +25,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.views.forms import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport

12
poetry.lock generated
View File

@ -520,16 +520,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-ajax-selects"
version = "2.2.1"
description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete."
optional = false
python-versions = "*"
files = [
{file = "django-ajax-selects-2.2.1.tar.gz", hash = "sha256:996ffb38dff1a621b358613afdf2681dbf261e5976da3c30a75e9b08fd81a887"},
]
[[package]] [[package]]
name = "django-countries" name = "django-countries"
version = "7.6.1" version = "7.6.1"
@ -2691,4 +2681,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0" content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2"

View File

@ -30,7 +30,6 @@ django-jinja = "^2.11"
cryptography = "^43.0.0" cryptography = "^43.0.0"
django-phonenumber-field = "^8.0.0" django-phonenumber-field = "^8.0.0"
phonenumbers = "^8.13" phonenumbers = "^8.13"
django-ajax-selects = "^2.2.1"
reportlab = "^4.2" reportlab = "^4.2"
django-haystack = "^3.2.1" django-haystack = "^3.2.1"
xapian-haystack = "^3.0.1" xapian-haystack = "^3.0.1"

View File

@ -23,7 +23,6 @@
# #
import logging import logging
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse from django.urls import reverse
@ -35,6 +34,7 @@ from django.views.generic.edit import FormView
from core.models import OperationLog, SithFile, User from core.models import OperationLog, SithFile, User
from core.views import CanEditPropMixin from core.views import CanEditPropMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Customer from counter.models import Customer
from forum.models import ForumMessageMeta from forum.models import ForumMessageMeta
@ -156,17 +156,29 @@ def delete_all_forum_user_messages(
class MergeForm(forms.Form): class MergeForm(forms.Form):
user1 = AutoCompleteSelectField( user1 = forms.ModelChoiceField(
"users", label=_("User that will be kept"), help_text=None, required=True label=_("User that will be kept"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
user2 = AutoCompleteSelectField( user2 = forms.ModelChoiceField(
"users", label=_("User that will be deleted"), help_text=None, required=True label=_("User that will be deleted"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
class SelectUserForm(forms.Form): class SelectUserForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", label=_("User to be selected"), help_text=None, required=True label=_("User to be selected"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -1,3 +1,6 @@
from typing import Annotated
from annotated_types import MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.urls import reverse from django.urls import reverse
@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanView, IsInGroup, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.models import Notification, User from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
PictureFilterSchema, PictureFilterSchema,
@ -22,6 +26,18 @@ from sas.schemas import (
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID) IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
@api_controller("/sas/album")
class AlbumController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[AlbumSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_album(self, search: Annotated[str, MinLen(1)]):
return Album.objects.filter(name__icontains=search)
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@route.get( @route.get(

View File

@ -1,14 +1,14 @@
from typing import Any from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.views import MultipleImageField from core.views import MultipleImageField
from core.views.forms import SelectDate from core.views.forms import SelectDate
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest from core.views.widgets.select import AutoCompleteSelectMultipleGroup
from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.select import AutoCompleteSelectAlbum
class SASForm(forms.Form): class SASForm(forms.Form):
@ -62,34 +62,24 @@ class SASForm(forms.Form):
) )
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
)
class PictureEditForm(forms.ModelForm): class PictureEditForm(forms.ModelForm):
class Meta: class Meta:
model = Picture model = Picture
fields = ["name", "parent"] fields = ["name", "parent"]
widgets = {"parent": AutoCompleteSelectAlbum}
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {
"parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@ -1,11 +1,24 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from django.urls import reverse from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema, UserProfileSchema from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Picture, PictureModerationRequest from sas.models import Album, Picture, PictureModerationRequest
class AlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name"]
path: str
@staticmethod
def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name)
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):

View File

@ -189,32 +189,12 @@
} }
>form { >form {
>p { input, .ts-wrapper {
box-sizing: border-box;
}
>.results_on_deck>div {
position: relative;
display: flex;
align-items: center;
word-break: break-word;
>span {
position: absolute;
top: 0;
right: 0;
}
}
input {
min-width: 100%; min-width: 100%;
max-width: 100%; max-width: 100%;
margin: 5px;
box-sizing: border-box; box-sizing: border-box;
} }
button {
font-weight: bold;
}
} }
} }
} }

View File

@ -0,0 +1,30 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type AlbumSchema, albumSearchAlbum } from "#openapi";
@registerComponent("album-ajax-select")
export class AlbumAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await albumSearchAlbum({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -177,7 +177,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search;
this.selector.filter = (users: UserProfileSchema[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = []; const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} }
} }
return resp; return resp;
}; });
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId, (i: PictureSchema) => i.id === config.firstPictureId,
); );

View File

@ -1,12 +1,13 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('webpack/ajax-select-index.css') }}"> <link defer rel="stylesheet" href="{{ static('webpack/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script> <script defer src="{{ static('webpack/core/components/ajax-select-index.ts') }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script> <script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%} {%- endblock -%}
@ -157,12 +158,12 @@
<h5>{% trans %}People{% endtrans %}</h5> <h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %} {% if user.was_subscribed %}
<form @submit.prevent="submitIdentification" x-show="!!selector"> <form @submit.prevent="submitIdentification" x-show="!!selector">
<ajax-select <user-ajax-select
x-ref="search" x-ref="search"
multiple multiple
data-delay="300" delay="300"
data-placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}" placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
></ajax-select> ></user-ajax-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}

26
sas/widgets/select.py Normal file
View File

@ -0,0 +1,26 @@
from pydantic import TypeAdapter
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
from sas.models import Album
from sas.schemas import AlbumSchema
_js = ["webpack/sas/components/ajax-select-index.ts"]
class AutoCompleteSelectAlbum(AutoCompleteSelect):
component_name = "album-ajax-select"
model = Album
adapter = TypeAdapter(list[AlbumSchema])
js = _js
class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple):
component_name = "album-ajax-select"
model = Album
adapter = TypeAdapter(list[AlbumSchema])
js = _js

View File

@ -81,7 +81,6 @@ INSTALLED_APPS = (
"honeypot", "honeypot",
"django_jinja", "django_jinja",
"ninja_extra", "ninja_extra",
"ajax_select",
"haystack", "haystack",
"captcha", "captcha",
"core", "core",

View File

@ -13,7 +13,6 @@
# #
# #
from ajax_select import urls as ajax_select_urls
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
@ -59,7 +58,6 @@ urlpatterns = [
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")), path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")), path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("ajax_select/", include(ajax_select_urls)),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")), path("captcha/", include("captcha.urls")),

View File

@ -15,7 +15,6 @@
import random import random
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -25,6 +24,7 @@ from django.views.generic.edit import CreateView, FormView
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectUser
from subscription.models import Subscription from subscription.models import Subscription
@ -43,11 +43,11 @@ class SubscriptionForm(forms.ModelForm):
class Meta: class Meta:
model = Subscription model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"] fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
member = AutoCompleteSelectField("users", required=False, help_text=None)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["member"].required = False
self.fields |= forms.fields_for_model( self.fields |= forms.fields_for_model(
User, User,
fields=["first_name", "last_name", "email", "date_of_birth"], fields=["first_name", "last_name", "email", "date_of_birth"],

View File

@ -24,7 +24,6 @@
from datetime import date from datetime import date
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -49,6 +48,7 @@ from core.views import (
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView):
class AddUserForm(forms.Form): class AddUserForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", required=True, label=_("Select user"), help_text=None label=_("Select user"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -10,7 +10,7 @@ module.exports = {
.sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)")
.reduce((obj, el) => { .reduce((obj, el) => {
// We include the path inside the webpack folder in the name // We include the path inside the webpack folder in the name
const relativePath = []; let relativePath = [];
const fullPath = path.parse(el); const fullPath = path.parse(el);
for (const dir of fullPath.dir.split("/").reverse()) { for (const dir of fullPath.dir.split("/").reverse()) {
if (dir === "webpack") { if (dir === "webpack") {
@ -18,6 +18,8 @@ module.exports = {
} }
relativePath.push(dir); relativePath.push(dir);
} }
// We collected folders in reverse order, we put them back in the original order
relativePath = relativePath.reverse();
relativePath.push(fullPath.name); relativePath.push(fullPath.name);
obj[relativePath.join("/")] = `./${el}`; obj[relativePath.join("/")] = `./${el}`;
return obj; return obj;