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

View File

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

View File

@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@ -32,8 +32,7 @@ jobs:
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
export PATH="$HOME/.poetry/bin:$PATH"
pushd ${{secrets.SITH_PATH}}
cd ${{secrets.SITH_PATH}}
git fetch
git reset --hard origin/taiste
@ -41,7 +40,7 @@ jobs:
npm install
poetry run ./manage.py install_xapian
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
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>
$( function() {
var target_type = $('#id_target_type');
var user = $('#id_user_wrapper');
var club = $('#id_club_wrapper');
var club_account = $('#id_club_account_wrapper');
var company = $('#id_company_wrapper');
var user = $('user-ajax-select');
var club = $('club-ajax-select');
var club_account = $('club-account-ajax-select');
var company = $('company-ajax-select');
var other = $('#id_target_label');
var need_link = $('#id_need_link_full');
function update_targets () {

View File

@ -15,7 +15,6 @@
import collections
from ajax_select.fields import AutoCompleteSelectField
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
@ -39,6 +38,13 @@ from accounting.models import (
Operation,
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 (
CanCreateMixin,
CanEditMixin,
@ -47,6 +53,7 @@ from core.views import (
TabedViewMixin,
)
from core.views.forms import SelectDate, SelectFile
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling
# Main accounting view
@ -334,12 +341,30 @@ class OperationForm(forms.ModelForm):
"invoice": SelectFile,
}
user = AutoCompleteSelectField("users", help_text=None, required=False)
club_account = AutoCompleteSelectField(
"club_accounts", help_text=None, required=False
user = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
club_account = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClubAccount,
queryset=ClubAccount.objects.all(),
)
club = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClub,
queryset=Club.objects.all(),
)
company = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectCompany,
queryset=Company.objects.all(),
)
club = AutoCompleteSelectField("clubs", help_text=None, required=False)
company = AutoCompleteSelectField("companies", help_text=None, required=False)
need_link = forms.BooleanField(
label=_("Link this operation to the target account"),
required=False,
@ -817,8 +842,12 @@ class LabelDeleteView(CanEditMixin, DeleteView):
class CloseCustomerAccountForm(forms.Form):
user = AutoCompleteSelectField(
"users", label=_("Refound this account"), help_text=None, required=True
user = forms.ModelChoiceField(
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.conf import settings
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 core.models import User
from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectMultipleUser
from counter.models import Counter
@ -50,11 +50,12 @@ class MailingForm(forms.Form):
ACTION_NEW_SUBSCRIPTION = 2
ACTION_REMOVE_SUBSCRIPTION = 3
subscription_users = AutoCompleteSelectMultipleField(
"users",
subscription_users = forms.ModelMultipleChoiceField(
label=_("Users to add"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
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."""
cleaned_data = super().clean()
users = []
for user_id 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"
)
for user in cleaned_data["subscription_users"]:
if not user.email:
raise forms.ValidationError(
_("One of the selected users doesn't have an email address"),
@ -180,11 +176,12 @@ class ClubMemberForm(forms.Form):
error_css_class = "error"
required_css_class = "required"
users = AutoCompleteSelectMultipleField(
"users",
users = forms.ModelMultipleChoiceField(
label=_("Users to add"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, *args, **kwargs):
@ -238,12 +235,7 @@ class ClubMemberForm(forms.Form):
"""
cleaned_data = super().clean()
users = []
for user_id 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"
)
for user in cleaned_data["users"]:
if not user.is_subscribed:
raise forms.ValidationError(
_("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)
response = self.client.post(
self.members_url,
{"users": self.subscriber.id, "role": 3},
{"users": [self.subscriber.id], "role": 3},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
@ -266,7 +266,7 @@ class TestClubModel(TestClub):
response = self.client.post(
self.members_url,
{
"users": f"|{self.subscriber.id}|{self.krophil.id}|",
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
@ -330,7 +330,7 @@ class TestClubModel(TestClub):
response = self.client.post(
self.members_url,
{
"users": f"|{self.subscriber.id}|{9999}|",
"users": (self.subscriber.id, 9999),
"start_date": "12/06/2016",
"role": 3,
},
@ -629,7 +629,7 @@ class TestMailingForm(TestCase):
self.mail_url,
{
"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,
},
)
@ -715,16 +715,17 @@ class TestMailingForm(TestCase):
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|789|",
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
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.save()
@ -782,8 +783,11 @@ class TestMailingForm(TestCase):
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|%s|%s|%s|"
% (self.comunity.id, self.rbatsbak.id, self.krophil.id),
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.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,
TabedViewMixin,
)
from core.views.forms import MarkdownInput, SelectDateTime
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
# Sith object

View File

@ -11,11 +11,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
from core.models import User
from core.api_permissions import (
CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
@ -44,7 +49,7 @@ class MailingListController(ControllerBase):
return data
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
@api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
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)]
DEFAULT_DEPTH = 4

View File

@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
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
token = request.session.get("counter_token")
if not token:
return False
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 annotated_types import MinLen
@ -8,7 +9,7 @@ from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field
from core.models import User
from core.models import Group, SithFile, User
class SimpleUserSchema(ModelSchema):
@ -45,6 +46,24 @@ class UserProfileSchema(ModelSchema):
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):
search: Annotated[str, MinLen(1)]
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 {
display: inline-block;
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,
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
previewRender: (plainText: string, preview: MarkdownInput) => {
/* 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 = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
@ -22,7 +27,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
};
func(plainText, preview);
return null;
}, 300),
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
toolbar: [
{
@ -185,8 +191,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
@registerComponent("markdown-input")
class MarkdownInput extends inheritHtmlElement("textarea") {
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node));
connectedCallback() {
super.connectedCallback();
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 { client } from "#openapi";
interface PaginatedResponse<T> {
export interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
interface PaginatedRequest {
export interface PaginatedRequest {
query?: {
page?: number;
// 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 {
protected node: HTMLElementTagNameMap[K];
constructor() {
super();
connectedCallback(autoAddNode?: boolean) {
this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
@ -44,7 +43,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
this.removeAttributeNode(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);
}
}
};
}

View File

@ -7,7 +7,6 @@
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
<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/markdown.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'">
<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>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script>
@ -301,8 +302,6 @@
{% endif %}
{% block script %}
<script src="{{ static('ajax_select/js/ajax_select.js') }}"></script>
<script src="{{ url('javascript-catalog') }}"></script>
<script>
function showMenu() {
let navbar = document.getElementById("navbar-content");

View File

@ -33,7 +33,6 @@
{% endif %}
{% csrf_token %}
{% render_honeypot_field %}
<div>
<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>
<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>
{# The easymde script can be included twice, it's safe in the code #}
<script src="{{ statics.js }}" defer> </script>
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
</div>
</div>

View File

@ -146,39 +146,20 @@ class TestUserLogin:
"""Should not login a user correctly."""
response = client.post(
reverse("core:login"),
{
"username": user.username,
"password": "wrong-password",
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE,
},
{"username": user.username, "password": "wrong-password"},
)
assert response.status_code == 200
assert (
'<p class="alert alert-red">Votre nom d\'utilisateur '
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) 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
def test_login_success(self, client, user):
"""Should login a user correctly."""
response = client.post(
reverse("core:login"),
{
"username": user.username,
"password": "plop",
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE,
},
{"username": user.username, "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
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
from wsgiref.util import FileWrapper
from ajax_select import make_ajax_field
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
@ -39,6 +38,11 @@ from core.views import (
CanViewMixin,
can_view,
)
from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile,
AutoCompleteSelectUser,
)
from counter.utils import is_logged_in_counter
@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm):
class Meta:
model = SithFile
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)

View File

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

View File

@ -15,7 +15,6 @@
"""Views to manage Groups."""
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.urls import reverse_lazy
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.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import (
AutoCompleteSelectMultipleUser,
)
# Forms
@ -34,6 +36,15 @@ class EditMembersForm(forms.Form):
def __init__(self, *args, **kwargs):
self.current_users = kwargs.pop("users", [])
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(
User.objects.filter(id__in=self.current_users).all(),
label=_("Users to remove from group"),
@ -41,31 +52,6 @@ class EditMembersForm(forms.Form):
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
@ -110,8 +96,10 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
data = form.clean()
group = self.get_object()
if data["users_removed"]:
for user in data["users_removed"]:
group.users.remove(user)
if data["users_added"]:
for user in data["users_added"]:
group.users.add(user)
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.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):

View File

@ -77,7 +77,6 @@ from subscription.models import Subscription
from trombi.views import UserTrombiForm
@method_decorator(check_honeypot, name="post")
class SithLoginView(views.LoginView):
"""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"
#
#
from ninja_extra import ControllerBase, api_controller, route
from typing import Annotated
from core.api_permissions import CanView, IsRoot
from counter.models import Counter
from counter.schemas import CounterSchema
from annotated_types import MinLen
from django.db.models import Q
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")
@ -37,3 +49,30 @@ class CounterController(ControllerBase):
for c in counters:
self.check_object_permissions(c)
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.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.widgets.select import AutoCompleteSelectClub
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import (
BillingInfo,
Counter,
@ -14,6 +19,11 @@ from counter.models import (
Refilling,
StudentCard,
)
from counter.widgets.select import (
AutoCompleteSelectMultipleCounter,
AutoCompleteSelectMultipleProduct,
AutoCompleteSelectProduct,
)
class BillingInfoForm(forms.ModelForm):
@ -68,8 +78,11 @@ class GetUserForm(forms.Form):
required=False,
widget=NFCTextInput,
)
id = AutoCompleteSelectField(
"users", required=False, label=_("Select user"), help_text=None
id = forms.CharField(
label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
)
def as_p(self):
@ -81,9 +94,13 @@ class GetUserForm(forms.Form):
cus = None
if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = StudentCard.objects.filter(uid=cleaned_data["code"])
if card.exists():
cus = card.first().customer
card = (
StudentCard.objects.filter(uid=cleaned_data["code"])
.select_related("customer")
.first()
)
if card is not None:
cus = card.customer
if cus is None:
cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
@ -122,8 +139,10 @@ class CounterEditForm(forms.ModelForm):
model = Counter
fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
products = make_ajax_field(Counter, "products", "products", help_text="")
widgets = {
"sellers": AutoCompleteSelectMultipleUser,
"products": AutoCompleteSelectMultipleProduct,
}
class ProductEditForm(forms.ModelForm):
@ -145,44 +164,37 @@ class ProductEditForm(forms.ModelForm):
"tray",
"archived",
]
widgets = {
"parent_product": AutoCompleteSelectMultipleProduct,
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
}
parent_product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Parent product"), required=False
)
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="",
counters = forms.ModelMultipleChoiceField(
help_text=None,
label=_("Counters"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = [
str(c.id) for c in self.instance.counters.all()
]
self.fields["counters"].initial = self.instance.counters.all()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
if self.fields["counters"].initial:
for cid in self.fields["counters"].initial:
c = Counter.objects.filter(id=int(cid)).first()
c.products.remove(self.instance)
c.save()
for cid in self.cleaned_data["counters"]:
c = Counter.objects.filter(id=int(cid)).first()
c.products.add(self.instance)
c.save()
# Remove the product from all counter it was added to
# It will then only be added to selected counters
for counter in self.fields["counters"].initial:
counter.products.remove(self.instance)
counter.save()
for counter in self.cleaned_data["counters"]:
counter.products.add(self.instance)
counter.save()
return ret
@ -199,8 +211,7 @@ class EticketForm(forms.ModelForm):
class Meta:
model = Eticket
fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate}
product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Product"), required=True
)
widgets = {
"product": AutoCompleteSelectProduct,
"event_date": SelectDate,
}

View File

@ -1,4 +1,5 @@
import logging
import time
from smtplib import SMTPException
from django.conf import settings
@ -25,9 +26,34 @@ class Command(BaseCommand):
self.logger.setLevel(logging.INFO)
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):
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")
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 = []
for user in users:
is_success = self._send_mail(user)
@ -38,6 +64,10 @@ class Command(BaseCommand):
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)
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 counter.models import Counter
from counter.models import Counter, Product
class CounterSchema(ModelSchema):
@ -11,3 +14,19 @@ class CounterSchema(ModelSchema):
class Meta:
model = Counter
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>
Bonjour,
</p>
{% trans %}Hello{% endtrans %},
<p>
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
{% trans trimmed date=last_subscription_date|date(DATETIME_FORMAT) -%}
You received this email because your last subscription to the
Students' association ended on {{ date }}.
{%- endtrans -%}
</p>
{%- endtrans %}
<p>
{%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
{% trans trimmed date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back
to the AE.
The money present on your account will therefore be recovered in full
on {{ date }}, for a total of {{ amount }} €.
{%- endtrans -%}
</p>
{%- endtrans %}
<p>
{%- trans -%}However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.{%- endtrans -%}
</p>
{% trans trimmed -%}
However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.
{%- endtrans %}
{% if balance >= 10 %}
<p>
{%- trans -%}You can also request a refund by sending an email to
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a>
before the aforementioned date.{%- endtrans -%}
</p>
{% endif %}
{% if balance >= 10 -%}
{% trans trimmed -%}
You can also request a refund by sending an email to ae@utbm.fr
before the aforementioned date.
{%- endtrans %}
{%- endif %}
<p>
{% trans %}Sincerely{% endtrans %},
</p>
{% trans trimmed -%}
Whatever you decide, you won't be expelled from the association,
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>
L'association des étudiants de l'UTBM <br>
6, Boulevard Anatole France <br>
90000 Belfort
</p>
{% trans %}Sincerely{% endtrans %},
L'association des étudiants de l'UTBM
6, Boulevard Anatole France
90000 Belfort

View File

@ -13,7 +13,7 @@
<h4>{{ product_type or _("Uncategorized") }}</h4>
<ul>
{%- 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 -%}
</ul>
{%- else -%}

View File

@ -17,7 +17,7 @@ import re
from datetime import datetime, timedelta
from datetime import timezone as tz
from http import HTTPStatus
from operator import attrgetter
from operator import itemgetter
from typing import TYPE_CHECKING
from urllib.parse import parse_qs
@ -801,7 +801,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
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"
ordering = [
F("product_type__priority").desc(nulls_last=True),
@ -812,7 +812,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
def get_context_data(self, **kwargs):
res = super().get_context_data(**kwargs)
res["object_list"] = itertools.groupby(
res["object_list"], key=attrgetter("type_name")
res["object_list"], key=itemgetter("product_type__name")
)
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 à
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.
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.

View File

@ -1,7 +1,5 @@
from typing import TYPE_CHECKING
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from django import forms
from django.core.exceptions import PermissionDenied
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 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
if TYPE_CHECKING:
@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm):
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
widgets = {"program": MarkdownInput}
user = AutoCompleteSelectField(
"users", label=_("User to candidate"), help_text=None, required=True
)
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm):
class Meta:
model = Role
fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm):
"vote_groups",
"candidature_groups",
]
edit_groups = make_ajax_field(
Election, "edit_groups", "groups", help_text="", label=_("edit groups")
)
view_groups = make_ajax_field(
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"),
)
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
}
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
"""Verify that the selected user is in candidate group."""
obj = form.instance
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 (
obj.user == self.request.user or self.can_edit
):

View File

@ -25,7 +25,6 @@ import logging
import math
from functools import partial
from ajax_select import make_ajax_field
from django import forms
from django.conf import settings
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 honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub
from core.views import (
CanCreateMixin,
CanEditMixin,
@ -50,7 +50,11 @@ from core.views import (
CanViewMixin,
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
@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm):
"edit_groups",
"view_groups",
]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"owner_club": AutoCompleteSelectClub,
}
edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="")
view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="")
parent = ForumNameField(Forum.objects.all())
parent = ForumNameField(
Forum.objects.all(), widget=AutoCompleteSelect, required=False
)
class ForumCreateView(CanCreateMixin, CreateView):

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -22,87 +22,94 @@ msgstr ""
msgid "captured.%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"
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"
msgstr "Aucun résultat trouvé"
#: core/static/webpack/easymde-index.ts:31
#: core/static/webpack/core/components/easymde-index.ts:38
msgid "Heading"
msgstr "Titre"
#: core/static/webpack/easymde-index.ts:37
#: core/static/webpack/core/components/easymde-index.ts:44
msgid "Italic"
msgstr "Italique"
#: core/static/webpack/easymde-index.ts:43
#: core/static/webpack/core/components/easymde-index.ts:50
msgid "Bold"
msgstr "Gras"
#: core/static/webpack/easymde-index.ts:49
#: core/static/webpack/core/components/easymde-index.ts:56
msgid "Strikethrough"
msgstr "Barré"
#: core/static/webpack/easymde-index.ts:58
#: core/static/webpack/core/components/easymde-index.ts:65
msgid "Underline"
msgstr "Souligné"
#: core/static/webpack/easymde-index.ts:67
#: core/static/webpack/core/components/easymde-index.ts:74
msgid "Superscript"
msgstr "Exposant"
#: core/static/webpack/easymde-index.ts:76
#: core/static/webpack/core/components/easymde-index.ts:83
msgid "Subscript"
msgstr "Indice"
#: core/static/webpack/easymde-index.ts:82
#: core/static/webpack/core/components/easymde-index.ts:89
msgid "Code"
msgstr "Code"
#: core/static/webpack/easymde-index.ts:89
#: core/static/webpack/core/components/easymde-index.ts:96
msgid "Quote"
msgstr "Citation"
#: core/static/webpack/easymde-index.ts:95
#: core/static/webpack/core/components/easymde-index.ts:102
msgid "Unordered list"
msgstr "Liste non ordonnée"
#: core/static/webpack/easymde-index.ts:101
#: core/static/webpack/core/components/easymde-index.ts:108
msgid "Ordered list"
msgstr "Liste ordonnée"
#: core/static/webpack/easymde-index.ts:108
#: core/static/webpack/core/components/easymde-index.ts:115
msgid "Insert link"
msgstr "Insérer lien"
#: core/static/webpack/easymde-index.ts:114
#: core/static/webpack/core/components/easymde-index.ts:121
msgid "Insert image"
msgstr "Insérer image"
#: core/static/webpack/easymde-index.ts:120
#: core/static/webpack/core/components/easymde-index.ts:127
msgid "Insert table"
msgstr "Insérer tableau"
#: core/static/webpack/easymde-index.ts:127
#: core/static/webpack/core/components/easymde-index.ts:134
msgid "Clean block"
msgstr "Nettoyer bloc"
#: core/static/webpack/easymde-index.ts:134
#: core/static/webpack/core/components/easymde-index.ts:141
msgid "Toggle preview"
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"
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"
msgstr "Activer le plein écran"
#: core/static/webpack/easymde-index.ts:153
#: core/static/webpack/core/components/easymde-index.ts:160
msgid "Markdown guide"
msgstr "Guide markdown"
@ -119,9 +126,11 @@ msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: sas/static/webpack/sas/viewer-index.ts:271
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:234
msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/webpack/sas/viewer-index.ts:284
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:248
msgid "Couldn't delete picture"
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 core.models import User
from core.views.forms import MarkdownInput
from core.views.widgets.markdown import MarkdownInput
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)"]
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]]
name = "django-countries"
version = "7.6.1"
@ -2691,4 +2681,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0"
content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2"

View File

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

View File

@ -23,7 +23,6 @@
#
import logging
from ajax_select.fields import AutoCompleteSelectField
from django import forms
from django.core.exceptions import PermissionDenied
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.views import CanEditPropMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Customer
from forum.models import ForumMessageMeta
@ -156,17 +156,29 @@ def delete_all_forum_user_messages(
class MergeForm(forms.Form):
user1 = AutoCompleteSelectField(
"users", label=_("User that will be kept"), help_text=None, required=True
user1 = forms.ModelChoiceField(
label=_("User that will be kept"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
user2 = AutoCompleteSelectField(
"users", label=_("User that will be deleted"), help_text=None, required=True
user2 = forms.ModelChoiceField(
label=_("User that will be deleted"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class SelectUserForm(forms.Form):
user = AutoCompleteSelectField(
"users", label=_("User to be selected"), help_text=None, required=True
user = forms.ModelChoiceField(
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.db.models import F
from django.urls import reverse
@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
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 sas.models import PeoplePictureRelation, Picture
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (
AlbumSchema,
IdentifiedUserSchema,
ModerationRequestSchema,
PictureFilterSchema,
@ -22,6 +26,18 @@ from sas.schemas import (
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")
class PicturesController(ControllerBase):
@route.get(

View File

@ -1,14 +1,14 @@
from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views import MultipleImageField
from core.views.forms import SelectDate
from 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):
@ -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 Meta:
model = Picture
fields = ["name", "parent"]
parent = make_ajax_field(Picture, "parent", "files", help_text="")
widgets = {"parent": AutoCompleteSelectAlbum}
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
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"))
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)

View File

@ -1,11 +1,24 @@
from datetime import datetime
from pathlib import Path
from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
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):

View File

@ -189,32 +189,12 @@
}
>form {
>p {
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 {
input, .ts-wrapper {
min-width: 100%;
max-width: 100%;
margin: 5px;
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)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.filter = (users: UserProfileSchema[]) => {
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}
}
return resp;
};
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);

View File

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

View File

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

View File

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

View File

@ -24,7 +24,6 @@
from datetime import date
from ajax_select.fields import AutoCompleteSelectField
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
@ -49,6 +48,7 @@ from core.views import (
TabedViewMixin,
)
from core.views.forms import SelectDate
from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView):
class AddUserForm(forms.Form):
user = AutoCompleteSelectField(
"users", required=True, label=_("Select user"), help_text=None
user = forms.ModelChoiceField(
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)")
.reduce((obj, el) => {
// We include the path inside the webpack folder in the name
const relativePath = [];
let relativePath = [];
const fullPath = path.parse(el);
for (const dir of fullPath.dir.split("/").reverse()) {
if (dir === "webpack") {
@ -18,6 +18,8 @@ module.exports = {
}
relativePath.push(dir);
}
// We collected folders in reverse order, we put them back in the original order
relativePath = relativePath.reverse();
relativePath.push(fullPath.name);
obj[relativePath.join("/")] = `./${el}`;
return obj;