mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-24 18:14:22 +00:00
Merge pull request #918 from ae-utbm/taiste
Ajax search input enhancement, promo 25 logo and small improvements
This commit is contained in:
commit
0a5ddcea68
10
.github/actions/setup_project/action.yml
vendored
10
.github/actions/setup_project/action.yml
vendored
@ -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
|
||||
|
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@ -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
|
||||
|
7
.github/workflows/taiste.yml
vendored
7
.github/workflows/taiste.yml
vendored
@ -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
23
accounting/api.py
Normal 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
15
accounting/schemas.py
Normal 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"]
|
@ -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>`;
|
||||
}
|
||||
}
|
@ -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 () {
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
39
accounting/widgets/select.py
Normal file
39
accounting/widgets/select.py
Normal 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
22
club/api.py
Normal 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()
|
@ -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
9
club/schemas.py
Normal file
@ -0,0 +1,9 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from club.models import Club
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name"]
|
30
club/static/webpack/club/components/ajax-select-index.ts
Normal file
30
club/static/webpack/club/components/ajax-select-index.ts
Normal 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>`;
|
||||
}
|
||||
}
|
@ -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
23
club/widgets/select.py
Normal 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
|
@ -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
|
||||
|
||||
|
35
core/api.py
35
core/api.py
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
141
core/lookups.py
141
core/lookups.py
@ -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
|
@ -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(
|
||||
|
55
core/static/core/components/ajax-select.scss
Normal file
55
core/static/core/components/ajax-select.scss
Normal 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
BIN
core/static/core/img/promo_25.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
@ -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;
|
||||
|
@ -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("");
|
||||
});
|
||||
}
|
||||
}
|
183
core/static/webpack/core/components/ajax-select-base.ts
Normal file
183
core/static/webpack/core/components/ajax-select-base.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
100
core/static/webpack/core/components/ajax-select-index.ts
Normal file
100
core/static/webpack/core/components/ajax-select-index.ts
Normal 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>`;
|
||||
}
|
||||
}
|
@ -13,16 +13,22 @@ 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> => {
|
||||
preview.innerHTML = (
|
||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||
).data as string;
|
||||
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;
|
||||
return null;
|
||||
};
|
||||
func(plainText, preview);
|
||||
return null;
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
33
core/static/webpack/core/components/include-index.ts
Normal file
33
core/static/webpack/core/components/include-index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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.appendChild(this.node);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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");
|
||||
|
@ -33,7 +33,6 @@
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% render_honeypot_field %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
||||
|
23
core/templates/core/widgets/autocomplete_select.jinja
Normal file
23
core/templates/core/widgets/autocomplete_select.jinja
Normal 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 }}>
|
@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
<script-once src="{{ statics.js }}" defer></script-once>
|
||||
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
||||
|
||||
{# 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>
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
|
||||
</div>
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,10 +96,12 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
|
||||
data = form.clean()
|
||||
group = self.get_object()
|
||||
for user in data["users_removed"]:
|
||||
group.users.remove(user)
|
||||
for user in data["users_added"]:
|
||||
group.users.add(user)
|
||||
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()
|
||||
|
||||
return resp
|
||||
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
||||
|
15
core/views/widgets/markdown.py
Normal file
15
core/views/widgets/markdown.py
Normal 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
|
111
core/views/widgets/select.py
Normal file
111
core/views/widgets/select.py
Normal 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])
|
@ -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()
|
||||
)
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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 !")
|
||||
|
||||
|
@ -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"]
|
||||
|
@ -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>`;
|
||||
}
|
||||
}
|
@ -1,43 +1,40 @@
|
||||
<p>
|
||||
Bonjour,
|
||||
</p>
|
||||
{% trans %}Hello{% endtrans %},
|
||||
|
||||
<p>
|
||||
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
|
||||
You received this email because your last subscription to the
|
||||
Students' association ended on {{ date }}.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
{% 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>
|
||||
{%- trans 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>
|
||||
{% 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>
|
||||
{%- 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
|
||||
|
@ -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 -%}
|
||||
|
@ -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
35
counter/widgets/select.py
Normal 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
|
@ -1,4 +1,4 @@
|
||||
gq## Objectifs
|
||||
## Objectifs
|
||||
|
||||
Le but de ce projet est de fournir à
|
||||
l'Association des Étudiants de l'UTBM
|
||||
|
@ -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.
|
@ -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
|
||||
):
|
||||
|
@ -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
@ -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"
|
||||
|
@ -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
12
poetry.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
20
sas/api.py
20
sas/api.py
@ -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(
|
||||
|
26
sas/forms.py
26
sas/forms.py
@ -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)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
30
sas/static/webpack/sas/components/ajax-select-index.ts
Normal file
30
sas/static/webpack/sas/components/ajax-select-index.ts
Normal 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>`;
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
|
@ -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
26
sas/widgets/select.py
Normal 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
|
@ -81,7 +81,6 @@ INSTALLED_APPS = (
|
||||
"honeypot",
|
||||
"django_jinja",
|
||||
"ninja_extra",
|
||||
"ajax_select",
|
||||
"haystack",
|
||||
"captcha",
|
||||
"core",
|
||||
|
@ -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")),
|
||||
|
@ -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"],
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user