mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-15 02:33:22 +00:00
commit
7cc13ea669
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>
|
<script>
|
||||||
$( function() {
|
$( function() {
|
||||||
var target_type = $('#id_target_type');
|
var target_type = $('#id_target_type');
|
||||||
var user = $('#id_user_wrapper');
|
var user = $('user-ajax-select');
|
||||||
var club = $('#id_club_wrapper');
|
var club = $('club-ajax-select');
|
||||||
var club_account = $('#id_club_account_wrapper');
|
var club_account = $('club-account-ajax-select');
|
||||||
var company = $('#id_company_wrapper');
|
var company = $('company-ajax-select');
|
||||||
var other = $('#id_target_label');
|
var other = $('#id_target_label');
|
||||||
var need_link = $('#id_need_link_full');
|
var need_link = $('#id_need_link_full');
|
||||||
function update_targets () {
|
function update_targets () {
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
import collections
|
import collections
|
||||||
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
@ -39,6 +38,13 @@ from accounting.models import (
|
|||||||
Operation,
|
Operation,
|
||||||
SimplifiedAccountingType,
|
SimplifiedAccountingType,
|
||||||
)
|
)
|
||||||
|
from accounting.widgets.select import (
|
||||||
|
AutoCompleteSelectClubAccount,
|
||||||
|
AutoCompleteSelectCompany,
|
||||||
|
)
|
||||||
|
from club.models import Club
|
||||||
|
from club.widgets.select import AutoCompleteSelectClub
|
||||||
|
from core.models import User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
CanCreateMixin,
|
CanCreateMixin,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -47,6 +53,7 @@ from core.views import (
|
|||||||
TabedViewMixin,
|
TabedViewMixin,
|
||||||
)
|
)
|
||||||
from core.views.forms import SelectDate, SelectFile
|
from core.views.forms import SelectDate, SelectFile
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectUser
|
||||||
from counter.models import Counter, Product, Selling
|
from counter.models import Counter, Product, Selling
|
||||||
|
|
||||||
# Main accounting view
|
# Main accounting view
|
||||||
@ -334,12 +341,30 @@ class OperationForm(forms.ModelForm):
|
|||||||
"invoice": SelectFile,
|
"invoice": SelectFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
user = AutoCompleteSelectField("users", help_text=None, required=False)
|
user = forms.ModelChoiceField(
|
||||||
club_account = AutoCompleteSelectField(
|
help_text=None,
|
||||||
"club_accounts", help_text=None, required=False
|
required=False,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
)
|
||||||
|
club_account = forms.ModelChoiceField(
|
||||||
|
help_text=None,
|
||||||
|
required=False,
|
||||||
|
widget=AutoCompleteSelectClubAccount,
|
||||||
|
queryset=ClubAccount.objects.all(),
|
||||||
|
)
|
||||||
|
club = forms.ModelChoiceField(
|
||||||
|
help_text=None,
|
||||||
|
required=False,
|
||||||
|
widget=AutoCompleteSelectClub,
|
||||||
|
queryset=Club.objects.all(),
|
||||||
|
)
|
||||||
|
company = forms.ModelChoiceField(
|
||||||
|
help_text=None,
|
||||||
|
required=False,
|
||||||
|
widget=AutoCompleteSelectCompany,
|
||||||
|
queryset=Company.objects.all(),
|
||||||
)
|
)
|
||||||
club = AutoCompleteSelectField("clubs", help_text=None, required=False)
|
|
||||||
company = AutoCompleteSelectField("companies", help_text=None, required=False)
|
|
||||||
need_link = forms.BooleanField(
|
need_link = forms.BooleanField(
|
||||||
label=_("Link this operation to the target account"),
|
label=_("Link this operation to the target account"),
|
||||||
required=False,
|
required=False,
|
||||||
@ -817,8 +842,12 @@ class LabelDeleteView(CanEditMixin, DeleteView):
|
|||||||
|
|
||||||
|
|
||||||
class CloseCustomerAccountForm(forms.Form):
|
class CloseCustomerAccountForm(forms.Form):
|
||||||
user = AutoCompleteSelectField(
|
user = forms.ModelChoiceField(
|
||||||
"users", label=_("Refound this account"), help_text=None, required=True
|
label=_("Refound this account"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -30,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDate, SelectDateTime
|
from core.views.forms import SelectDate, SelectDateTime
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectMultipleUser
|
||||||
from counter.models import Counter
|
from counter.models import Counter
|
||||||
|
|
||||||
|
|
||||||
@ -50,11 +50,12 @@ class MailingForm(forms.Form):
|
|||||||
ACTION_NEW_SUBSCRIPTION = 2
|
ACTION_NEW_SUBSCRIPTION = 2
|
||||||
ACTION_REMOVE_SUBSCRIPTION = 3
|
ACTION_REMOVE_SUBSCRIPTION = 3
|
||||||
|
|
||||||
subscription_users = AutoCompleteSelectMultipleField(
|
subscription_users = forms.ModelMultipleChoiceField(
|
||||||
"users",
|
|
||||||
label=_("Users to add"),
|
label=_("Users to add"),
|
||||||
help_text=_("Search users to add (one or more)."),
|
help_text=_("Search users to add (one or more)."),
|
||||||
required=False,
|
required=False,
|
||||||
|
widget=AutoCompleteSelectMultipleUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, club_id, user_id, mailings, *args, **kwargs):
|
def __init__(self, club_id, user_id, mailings, *args, **kwargs):
|
||||||
@ -111,12 +112,7 @@ class MailingForm(forms.Form):
|
|||||||
"""Convert given users into real users and check their validity."""
|
"""Convert given users into real users and check their validity."""
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
users = []
|
users = []
|
||||||
for user_id in cleaned_data["subscription_users"]:
|
for user in cleaned_data["subscription_users"]:
|
||||||
user = User.objects.filter(id=user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("One of the selected users doesn't exist"), code="invalid"
|
|
||||||
)
|
|
||||||
if not user.email:
|
if not user.email:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("One of the selected users doesn't have an email address"),
|
_("One of the selected users doesn't have an email address"),
|
||||||
@ -180,11 +176,12 @@ class ClubMemberForm(forms.Form):
|
|||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
|
|
||||||
users = AutoCompleteSelectMultipleField(
|
users = forms.ModelMultipleChoiceField(
|
||||||
"users",
|
|
||||||
label=_("Users to add"),
|
label=_("Users to add"),
|
||||||
help_text=_("Search users to add (one or more)."),
|
help_text=_("Search users to add (one or more)."),
|
||||||
required=False,
|
required=False,
|
||||||
|
widget=AutoCompleteSelectMultipleUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -238,12 +235,7 @@ class ClubMemberForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
users = []
|
users = []
|
||||||
for user_id in cleaned_data["users"]:
|
for user in cleaned_data["users"]:
|
||||||
user = User.objects.filter(id=user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("One of the selected users doesn't exist"), code="invalid"
|
|
||||||
)
|
|
||||||
if not user.is_subscribed:
|
if not user.is_subscribed:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("User must be subscriber to take part to a club"), code="invalid"
|
_("User must be subscriber to take part to a club"), code="invalid"
|
||||||
|
9
club/schemas.py
Normal file
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)
|
self.client.force_login(self.root)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{"users": self.subscriber.id, "role": 3},
|
{"users": [self.subscriber.id], "role": 3},
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, self.members_url)
|
self.assertRedirects(response, self.members_url)
|
||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
@ -266,7 +266,7 @@ class TestClubModel(TestClub):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{
|
{
|
||||||
"users": f"|{self.subscriber.id}|{self.krophil.id}|",
|
"users": (self.subscriber.id, self.krophil.id),
|
||||||
"role": 3,
|
"role": 3,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -330,7 +330,7 @@ class TestClubModel(TestClub):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{
|
{
|
||||||
"users": f"|{self.subscriber.id}|{9999}|",
|
"users": (self.subscriber.id, 9999),
|
||||||
"start_date": "12/06/2016",
|
"start_date": "12/06/2016",
|
||||||
"role": 3,
|
"role": 3,
|
||||||
},
|
},
|
||||||
@ -629,7 +629,7 @@ class TestMailingForm(TestCase):
|
|||||||
self.mail_url,
|
self.mail_url,
|
||||||
{
|
{
|
||||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||||
"subscription_users": "|%s|%s|" % (self.comunity.id, self.rbatsbak.id),
|
"subscription_users": (self.comunity.id, self.rbatsbak.id),
|
||||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -715,16 +715,17 @@ class TestMailingForm(TestCase):
|
|||||||
self.mail_url,
|
self.mail_url,
|
||||||
{
|
{
|
||||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||||
"subscription_users": "|789|",
|
"subscription_users": [789],
|
||||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
_("One of the selected users doesn't exist"), response.content.decode()
|
_("You must specify at least an user or an email address"),
|
||||||
|
response.content.decode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# An user has no email adress
|
# An user has no email address
|
||||||
self.krophil.email = ""
|
self.krophil.email = ""
|
||||||
self.krophil.save()
|
self.krophil.save()
|
||||||
|
|
||||||
@ -782,8 +783,11 @@ class TestMailingForm(TestCase):
|
|||||||
self.mail_url,
|
self.mail_url,
|
||||||
{
|
{
|
||||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||||
"subscription_users": "|%s|%s|%s|"
|
"subscription_users": (
|
||||||
% (self.comunity.id, self.rbatsbak.id, self.krophil.id),
|
self.comunity.id,
|
||||||
|
self.rbatsbak.id,
|
||||||
|
self.krophil.id,
|
||||||
|
),
|
||||||
"subscription_mailing": mde.id,
|
"subscription_mailing": mde.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
23
club/widgets/select.py
Normal file
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,
|
QuickNotifMixin,
|
||||||
TabedViewMixin,
|
TabedViewMixin,
|
||||||
)
|
)
|
||||||
from core.views.forms import MarkdownInput, SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
|
from core.views.widgets.markdown import MarkdownInput
|
||||||
|
|
||||||
# Sith object
|
# Sith object
|
||||||
|
|
||||||
|
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 ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from club.models import Mailing
|
from club.models import Mailing
|
||||||
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
|
from core.api_permissions import (
|
||||||
from core.models import User
|
CanAccessLookup,
|
||||||
|
CanView,
|
||||||
|
)
|
||||||
|
from core.models import Group, SithFile, User
|
||||||
from core.schemas import (
|
from core.schemas import (
|
||||||
FamilyGodfatherSchema,
|
FamilyGodfatherSchema,
|
||||||
|
GroupSchema,
|
||||||
MarkdownSchema,
|
MarkdownSchema,
|
||||||
|
SithFileSchema,
|
||||||
UserFamilySchema,
|
UserFamilySchema,
|
||||||
UserFilterSchema,
|
UserFilterSchema,
|
||||||
UserProfileSchema,
|
UserProfileSchema,
|
||||||
@ -44,7 +49,7 @@ class MailingListController(ControllerBase):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
|
@api_controller("/user", permissions=[CanAccessLookup])
|
||||||
class UserController(ControllerBase):
|
class UserController(ControllerBase):
|
||||||
@route.get("", response=list[UserProfileSchema])
|
@route.get("", response=list[UserProfileSchema])
|
||||||
def fetch_profiles(self, pks: Query[set[int]]):
|
def fetch_profiles(self, pks: Query[set[int]]):
|
||||||
@ -62,6 +67,30 @@ class UserController(ControllerBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/file")
|
||||||
|
class SithFileController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"/search",
|
||||||
|
response=PaginatedResponseSchema[SithFileSchema],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||||
|
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/group")
|
||||||
|
class GroupController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"/search",
|
||||||
|
response=PaginatedResponseSchema[GroupSchema],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||||
|
return Group.objects.filter(name__icontains=search).values()
|
||||||
|
|
||||||
|
|
||||||
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
||||||
DEFAULT_DEPTH = 4
|
DEFAULT_DEPTH = 4
|
||||||
|
|
||||||
|
@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission):
|
|||||||
"""Check that a user is logged in a counter."""
|
"""Check that a user is logged in a counter."""
|
||||||
|
|
||||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||||
if "/counter/" not in request.META["HTTP_REFERER"]:
|
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
|
||||||
return False
|
return False
|
||||||
token = request.session.get("counter_token")
|
token = request.session.get("counter_token")
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
return Counter.objects.filter(token=token).exists()
|
return Counter.objects.filter(token=token).exists()
|
||||||
|
|
||||||
|
|
||||||
|
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
|
||||||
|
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 typing import Annotated
|
||||||
|
|
||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
@ -8,7 +9,7 @@ from haystack.query import SearchQuerySet
|
|||||||
from ninja import FilterSchema, ModelSchema, Schema
|
from ninja import FilterSchema, ModelSchema, Schema
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
|
|
||||||
from core.models import User
|
from core.models import Group, SithFile, User
|
||||||
|
|
||||||
|
|
||||||
class SimpleUserSchema(ModelSchema):
|
class SimpleUserSchema(ModelSchema):
|
||||||
@ -45,6 +46,24 @@ class UserProfileSchema(ModelSchema):
|
|||||||
return obj.profile_pict.get_download_url()
|
return obj.profile_pict.get_download_url()
|
||||||
|
|
||||||
|
|
||||||
|
class SithFileSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = SithFile
|
||||||
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
path: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_path(obj: SithFile) -> str:
|
||||||
|
return str(Path(obj.get_parent_path()) / obj.name)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
|
||||||
class UserFilterSchema(FilterSchema):
|
class UserFilterSchema(FilterSchema):
|
||||||
search: Annotated[str, MinLen(1)]
|
search: Annotated[str, MinLen(1)]
|
||||||
exclude: list[int] | None = Field(
|
exclude: list[int] | None = Field(
|
||||||
|
45
core/static/core/components/ajax-select.scss
Normal file
45
core/static/core/components/ajax-select.scss
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/* 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.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;
|
||||||
|
}
|
||||||
|
}
|
@ -712,63 +712,6 @@ a:not(.button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tomselected {
|
|
||||||
margin: 10px 0 !important;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 100%;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select2-container--default {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-dropdown {
|
|
||||||
|
|
||||||
.select-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-control {
|
|
||||||
|
|
||||||
.item {
|
|
||||||
.fa-times {
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #e4e4e4;
|
|
||||||
border: 1px solid #aaa;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#news_details {
|
#news_details {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
@ -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,
|
element: textarea,
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
autoDownloadFontAwesome: false,
|
autoDownloadFontAwesome: false,
|
||||||
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
previewRender: (plainText: string, preview: MarkdownInput) => {
|
||||||
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
|
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||||
preview.innerHTML = (
|
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
const func = async (
|
||||||
).data as string;
|
plainText: string,
|
||||||
|
preview: MarkdownInput,
|
||||||
|
): Promise<null> => {
|
||||||
|
preview.innerHTML = (
|
||||||
|
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||||
|
).data as string;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
func(plainText, preview);
|
||||||
return null;
|
return null;
|
||||||
};
|
}, 300)(plainText, preview);
|
||||||
func(plainText, preview);
|
},
|
||||||
return null;
|
|
||||||
}, 300),
|
|
||||||
forceSync: true, // Avoid validation error on generic create view
|
forceSync: true, // Avoid validation error on generic create view
|
||||||
toolbar: [
|
toolbar: [
|
||||||
{
|
{
|
||||||
@ -185,8 +191,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
|
|
||||||
@registerComponent("markdown-input")
|
@registerComponent("markdown-input")
|
||||||
class MarkdownInput extends inheritHtmlElement("textarea") {
|
class MarkdownInput extends inheritHtmlElement("textarea") {
|
||||||
constructor() {
|
connectedCallback() {
|
||||||
super();
|
super.connectedCallback();
|
||||||
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node));
|
loadEasyMde(this.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
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 type { Client, Options, RequestResult } from "@hey-api/client-fetch";
|
||||||
import { client } from "#openapi";
|
import { client } from "#openapi";
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
count: number;
|
count: number;
|
||||||
next: string | null;
|
next: string | null;
|
||||||
previous: string | null;
|
previous: string | null;
|
||||||
results: T[];
|
results: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedRequest {
|
export interface PaginatedRequest {
|
||||||
query?: {
|
query?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
@ -30,8 +30,7 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
return class Inherited extends HTMLElement {
|
return class Inherited extends HTMLElement {
|
||||||
protected node: HTMLElementTagNameMap[K];
|
protected node: HTMLElementTagNameMap[K];
|
||||||
|
|
||||||
constructor() {
|
connectedCallback(autoAddNode?: boolean) {
|
||||||
super();
|
|
||||||
this.node = document.createElement(tagName);
|
this.node = document.createElement(tagName);
|
||||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||||
for (const attr of this.attributes) {
|
for (const attr of this.attributes) {
|
||||||
@ -44,7 +43,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
this.removeAttributeNode(attr);
|
this.removeAttributeNode(attr);
|
||||||
this.node.setAttributeNode(attr);
|
this.node.setAttributeNode(attr);
|
||||||
}
|
}
|
||||||
this.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="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
|
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||||
@ -21,6 +20,8 @@
|
|||||||
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
|
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
|
||||||
|
|
||||||
|
<script src="{{ url('javascript-catalog') }}"></script>
|
||||||
|
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
|
||||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
||||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||||
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
||||||
@ -301,8 +302,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ static('ajax_select/js/ajax_select.js') }}"></script>
|
|
||||||
<script src="{{ url('javascript-catalog') }}"></script>
|
|
||||||
<script>
|
<script>
|
||||||
function showMenu() {
|
function showMenu() {
|
||||||
let navbar = document.getElementById("navbar-content");
|
let navbar = document.getElementById("navbar-content");
|
||||||
|
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>
|
<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 #}
|
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||||
<script src="{{ statics.js }}" defer> </script>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
|
</div>
|
||||||
</div>
|
|
||||||
|
@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin
|
|||||||
# This file contains all the views that concern the page model
|
# This file contains all the views that concern the page model
|
||||||
from wsgiref.util import FileWrapper
|
from wsgiref.util import FileWrapper
|
||||||
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
@ -39,6 +38,11 @@ from core.views import (
|
|||||||
CanViewMixin,
|
CanViewMixin,
|
||||||
can_view,
|
can_view,
|
||||||
)
|
)
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelectMultipleGroup,
|
||||||
|
AutoCompleteSelectSithFile,
|
||||||
|
AutoCompleteSelectUser,
|
||||||
|
)
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = SithFile
|
model = SithFile
|
||||||
fields = ["parent", "owner", "edit_groups", "view_groups"]
|
fields = ["parent", "owner", "edit_groups", "view_groups"]
|
||||||
|
widgets = {
|
||||||
|
"parent": AutoCompleteSelectSithFile,
|
||||||
|
"owner": AutoCompleteSelectUser,
|
||||||
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
}
|
||||||
|
|
||||||
parent = make_ajax_field(SithFile, "parent", "files", help_text="")
|
|
||||||
edit_groups = make_ajax_field(
|
|
||||||
SithFile, "edit_groups", "groups", help_text="", label=_("edit group")
|
|
||||||
)
|
|
||||||
view_groups = make_ajax_field(
|
|
||||||
SithFile, "view_groups", "groups", help_text="", label=_("view group")
|
|
||||||
)
|
|
||||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,20 +23,16 @@
|
|||||||
import re
|
import re
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from captcha.fields import CaptchaField
|
from captcha.fields import CaptchaField
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.forms import (
|
from django.forms import (
|
||||||
CheckboxSelectMultiple,
|
CheckboxSelectMultiple,
|
||||||
DateInput,
|
DateInput,
|
||||||
DateTimeInput,
|
DateTimeInput,
|
||||||
Textarea,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
)
|
)
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
@ -47,6 +43,12 @@ from PIL import Image
|
|||||||
from antispam.forms import AntiSpamEmailField
|
from antispam.forms import AntiSpamEmailField
|
||||||
from core.models import Gift, Page, SithFile, User
|
from core.models import Gift, Page, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelect,
|
||||||
|
AutoCompleteSelectGroup,
|
||||||
|
AutoCompleteSelectMultipleGroup,
|
||||||
|
AutoCompleteSelectUser,
|
||||||
|
)
|
||||||
|
|
||||||
# Widgets
|
# Widgets
|
||||||
|
|
||||||
@ -65,19 +67,6 @@ class SelectDate(DateInput):
|
|||||||
super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
|
super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
class MarkdownInput(Textarea):
|
|
||||||
template_name = "core/widgets/markdown_textarea.jinja"
|
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
|
||||||
context = super().get_context(name, value, attrs)
|
|
||||||
|
|
||||||
context["statics"] = {
|
|
||||||
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
|
|
||||||
"css": staticfiles_storage.url("webpack/easymde-index.css"),
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class NFCTextInput(TextInput):
|
class NFCTextInput(TextInput):
|
||||||
template_name = "core/widgets/nfc.jinja"
|
template_name = "core/widgets/nfc.jinja"
|
||||||
|
|
||||||
@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form):
|
|||||||
],
|
],
|
||||||
label=_("Add"),
|
label=_("Add"),
|
||||||
)
|
)
|
||||||
user = AutoCompleteSelectField(
|
user = forms.ModelChoiceField(
|
||||||
"users", required=True, label=_("Select user"), help_text=""
|
label=_("Select user"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, user: User, **kwargs):
|
def __init__(self, *args, user: User, **kwargs):
|
||||||
@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Page
|
model = Page
|
||||||
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
||||||
|
widgets = {
|
||||||
edit_groups = make_ajax_field(
|
"parent": AutoCompleteSelect,
|
||||||
Page, "edit_groups", "groups", help_text="", label=_("edit groups")
|
"owner_group": AutoCompleteSelectGroup,
|
||||||
)
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
view_groups = make_ajax_field(
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
Page, "view_groups", "groups", help_text="", label=_("view groups")
|
}
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *arg, **kwargs):
|
def __init__(self, *arg, **kwargs):
|
||||||
super().__init__(*arg, **kwargs)
|
super().__init__(*arg, **kwargs)
|
||||||
@ -372,13 +364,12 @@ class PageForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Page
|
model = Page
|
||||||
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
||||||
|
widgets = {
|
||||||
edit_groups = make_ajax_field(
|
"parent": AutoCompleteSelect,
|
||||||
Page, "edit_groups", "groups", help_text="", label=_("edit groups")
|
"owner_group": AutoCompleteSelectGroup,
|
||||||
)
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
view_groups = make_ajax_field(
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
Page, "view_groups", "groups", help_text="", label=_("view groups")
|
}
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
"""Views to manage Groups."""
|
"""Views to manage Groups."""
|
||||||
|
|
||||||
from ajax_select.fields import AutoCompleteSelectMultipleField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -24,6 +23,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||||||
|
|
||||||
from core.models import RealGroup, User
|
from core.models import RealGroup, User
|
||||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelectMultipleUser,
|
||||||
|
)
|
||||||
|
|
||||||
# Forms
|
# Forms
|
||||||
|
|
||||||
@ -34,6 +36,15 @@ class EditMembersForm(forms.Form):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.current_users = kwargs.pop("users", [])
|
self.current_users = kwargs.pop("users", [])
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["users_added"] = forms.ModelMultipleChoiceField(
|
||||||
|
label=_("Users to add to group"),
|
||||||
|
help_text=_("Search users to add (one or more)."),
|
||||||
|
required=False,
|
||||||
|
widget=AutoCompleteSelectMultipleUser,
|
||||||
|
queryset=User.objects.exclude(id__in=self.current_users).all(),
|
||||||
|
)
|
||||||
|
|
||||||
self.fields["users_removed"] = forms.ModelMultipleChoiceField(
|
self.fields["users_removed"] = forms.ModelMultipleChoiceField(
|
||||||
User.objects.filter(id__in=self.current_users).all(),
|
User.objects.filter(id__in=self.current_users).all(),
|
||||||
label=_("Users to remove from group"),
|
label=_("Users to remove from group"),
|
||||||
@ -41,31 +52,6 @@ class EditMembersForm(forms.Form):
|
|||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
)
|
)
|
||||||
|
|
||||||
users_added = AutoCompleteSelectMultipleField(
|
|
||||||
"users",
|
|
||||||
label=_("Users to add to group"),
|
|
||||||
help_text=_("Search users to add (one or more)."),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean_users_added(self):
|
|
||||||
"""Check that the user is not trying to add an user already in the group."""
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
users_added = cleaned_data.get("users_added", None)
|
|
||||||
if not users_added:
|
|
||||||
return users_added
|
|
||||||
|
|
||||||
current_users = [
|
|
||||||
str(id_) for id_ in self.current_users.values_list("id", flat=True)
|
|
||||||
]
|
|
||||||
for user in users_added:
|
|
||||||
if user in current_users:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("You can not add the same user twice"), code="invalid"
|
|
||||||
)
|
|
||||||
|
|
||||||
return users_added
|
|
||||||
|
|
||||||
|
|
||||||
# Views
|
# Views
|
||||||
|
|
||||||
@ -110,10 +96,12 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
|||||||
|
|
||||||
data = form.clean()
|
data = form.clean()
|
||||||
group = self.get_object()
|
group = self.get_object()
|
||||||
for user in data["users_removed"]:
|
if data["users_removed"]:
|
||||||
group.users.remove(user)
|
for user in data["users_removed"]:
|
||||||
for user in data["users_added"]:
|
group.users.remove(user)
|
||||||
group.users.add(user)
|
if data["users_added"]:
|
||||||
|
for user in data["users_added"]:
|
||||||
|
group.users.add(user)
|
||||||
group.save()
|
group.save()
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||||||
|
|
||||||
from core.models import LockError, Page, PageRev
|
from core.models import LockError, Page, PageRev
|
||||||
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
|
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
|
||||||
from core.views.forms import MarkdownInput, PageForm, PagePropForm
|
from core.views.forms import PageForm, PagePropForm
|
||||||
|
from core.views.widgets.markdown import MarkdownInput
|
||||||
|
|
||||||
|
|
||||||
class CanEditPagePropMixin(CanEditPropMixin):
|
class CanEditPagePropMixin(CanEditPropMixin):
|
||||||
|
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"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from ninja_extra import ControllerBase, api_controller, route
|
from typing import Annotated
|
||||||
|
|
||||||
from core.api_permissions import CanView, IsRoot
|
from annotated_types import MinLen
|
||||||
from counter.models import Counter
|
from django.db.models import Q
|
||||||
from counter.schemas import CounterSchema
|
from ninja import Query
|
||||||
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
|
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
||||||
|
from counter.models import Counter, Product
|
||||||
|
from counter.schemas import (
|
||||||
|
CounterFilterSchema,
|
||||||
|
CounterSchema,
|
||||||
|
ProductSchema,
|
||||||
|
SimplifiedCounterSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/counter")
|
@api_controller("/counter")
|
||||||
@ -37,3 +49,30 @@ class CounterController(ControllerBase):
|
|||||||
for c in counters:
|
for c in counters:
|
||||||
self.check_object_permissions(c)
|
self.check_object_permissions(c)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
|
@route.get(
|
||||||
|
"/search",
|
||||||
|
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_counter(self, filters: Query[CounterFilterSchema]):
|
||||||
|
return filters.filter(Counter.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/product")
|
||||||
|
class ProductController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"/search",
|
||||||
|
response=PaginatedResponseSchema[ProductSchema],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_products(self, search: Annotated[str, MinLen(1)]):
|
||||||
|
return (
|
||||||
|
Product.objects.filter(
|
||||||
|
Q(name__icontains=search) | Q(code__icontains=search)
|
||||||
|
)
|
||||||
|
.filter(archived=False)
|
||||||
|
.values()
|
||||||
|
)
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
from ajax_select import make_ajax_field
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
|
|
||||||
|
from club.widgets.select import AutoCompleteSelectClub
|
||||||
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelect,
|
||||||
|
AutoCompleteSelectMultipleGroup,
|
||||||
|
AutoCompleteSelectMultipleUser,
|
||||||
|
AutoCompleteSelectUser,
|
||||||
|
)
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
BillingInfo,
|
BillingInfo,
|
||||||
Counter,
|
Counter,
|
||||||
@ -14,6 +19,11 @@ from counter.models import (
|
|||||||
Refilling,
|
Refilling,
|
||||||
StudentCard,
|
StudentCard,
|
||||||
)
|
)
|
||||||
|
from counter.widgets.select import (
|
||||||
|
AutoCompleteSelectMultipleCounter,
|
||||||
|
AutoCompleteSelectMultipleProduct,
|
||||||
|
AutoCompleteSelectProduct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BillingInfoForm(forms.ModelForm):
|
class BillingInfoForm(forms.ModelForm):
|
||||||
@ -68,8 +78,11 @@ class GetUserForm(forms.Form):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=NFCTextInput,
|
widget=NFCTextInput,
|
||||||
)
|
)
|
||||||
id = AutoCompleteSelectField(
|
id = forms.CharField(
|
||||||
"users", required=False, label=_("Select user"), help_text=None
|
label=_("Select user"),
|
||||||
|
help_text=None,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def as_p(self):
|
def as_p(self):
|
||||||
@ -122,8 +135,10 @@ class CounterEditForm(forms.ModelForm):
|
|||||||
model = Counter
|
model = Counter
|
||||||
fields = ["sellers", "products"]
|
fields = ["sellers", "products"]
|
||||||
|
|
||||||
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
|
widgets = {
|
||||||
products = make_ajax_field(Counter, "products", "products", help_text="")
|
"sellers": AutoCompleteSelectMultipleUser,
|
||||||
|
"products": AutoCompleteSelectMultipleProduct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ProductEditForm(forms.ModelForm):
|
class ProductEditForm(forms.ModelForm):
|
||||||
@ -145,44 +160,37 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"tray",
|
"tray",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"parent_product": AutoCompleteSelectMultipleProduct,
|
||||||
|
"product_type": AutoCompleteSelect,
|
||||||
|
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"club": AutoCompleteSelectClub,
|
||||||
|
}
|
||||||
|
|
||||||
parent_product = AutoCompleteSelectField(
|
counters = forms.ModelMultipleChoiceField(
|
||||||
"products", show_help_text=False, label=_("Parent product"), required=False
|
help_text=None,
|
||||||
)
|
|
||||||
buying_groups = AutoCompleteSelectMultipleField(
|
|
||||||
"groups",
|
|
||||||
show_help_text=False,
|
|
||||||
help_text="",
|
|
||||||
label=_("Buying groups"),
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
club = AutoCompleteSelectField("clubs", show_help_text=False)
|
|
||||||
counters = AutoCompleteSelectMultipleField(
|
|
||||||
"counters",
|
|
||||||
show_help_text=False,
|
|
||||||
help_text="",
|
|
||||||
label=_("Counters"),
|
label=_("Counters"),
|
||||||
required=False,
|
required=False,
|
||||||
|
widget=AutoCompleteSelectMultipleCounter,
|
||||||
|
queryset=Counter.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if self.instance.id:
|
if self.instance.id:
|
||||||
self.fields["counters"].initial = [
|
self.fields["counters"].initial = self.instance.counters.all()
|
||||||
str(c.id) for c in self.instance.counters.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
ret = super().save(*args, **kwargs)
|
ret = super().save(*args, **kwargs)
|
||||||
if self.fields["counters"].initial:
|
if self.fields["counters"].initial:
|
||||||
for cid in self.fields["counters"].initial:
|
# Remove the product from all counter it was added to
|
||||||
c = Counter.objects.filter(id=int(cid)).first()
|
# It will then only be added to selected counters
|
||||||
c.products.remove(self.instance)
|
for counter in self.fields["counters"].initial:
|
||||||
c.save()
|
counter.products.remove(self.instance)
|
||||||
for cid in self.cleaned_data["counters"]:
|
counter.save()
|
||||||
c = Counter.objects.filter(id=int(cid)).first()
|
for counter in self.cleaned_data["counters"]:
|
||||||
c.products.add(self.instance)
|
counter.products.add(self.instance)
|
||||||
c.save()
|
counter.save()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@ -199,8 +207,7 @@ class EticketForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Eticket
|
model = Eticket
|
||||||
fields = ["product", "banner", "event_title", "event_date"]
|
fields = ["product", "banner", "event_title", "event_date"]
|
||||||
widgets = {"event_date": SelectDate}
|
widgets = {
|
||||||
|
"product": AutoCompleteSelectProduct,
|
||||||
product = AutoCompleteSelectField(
|
"event_date": SelectDate,
|
||||||
"products", show_help_text=False, label=_("Product"), required=True
|
}
|
||||||
)
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
from ninja import ModelSchema
|
from typing import Annotated
|
||||||
|
|
||||||
|
from annotated_types import MinLen
|
||||||
|
from ninja import Field, FilterSchema, ModelSchema
|
||||||
|
|
||||||
from core.schemas import SimpleUserSchema
|
from core.schemas import SimpleUserSchema
|
||||||
from counter.models import Counter
|
from counter.models import Counter, Product
|
||||||
|
|
||||||
|
|
||||||
class CounterSchema(ModelSchema):
|
class CounterSchema(ModelSchema):
|
||||||
@ -11,3 +14,19 @@ class CounterSchema(ModelSchema):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Counter
|
model = Counter
|
||||||
fields = ["id", "name", "type", "club", "products"]
|
fields = ["id", "name", "type", "club", "products"]
|
||||||
|
|
||||||
|
|
||||||
|
class CounterFilterSchema(FilterSchema):
|
||||||
|
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")
|
||||||
|
|
||||||
|
|
||||||
|
class SimplifiedCounterSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Counter
|
||||||
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = ["id", "name", "code"]
|
||||||
|
@ -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>`;
|
||||||
|
}
|
||||||
|
}
|
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,7 +1,5 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@ -13,7 +11,13 @@ from django.views.generic import DetailView, ListView
|
|||||||
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
||||||
|
|
||||||
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
|
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
|
||||||
from core.views.forms import MarkdownInput, SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
|
from core.views.widgets.markdown import MarkdownInput
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelect,
|
||||||
|
AutoCompleteSelectMultipleGroup,
|
||||||
|
AutoCompleteSelectUser,
|
||||||
|
)
|
||||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Candidature
|
model = Candidature
|
||||||
fields = ["user", "role", "program", "election_list"]
|
fields = ["user", "role", "program", "election_list"]
|
||||||
widgets = {"program": MarkdownInput}
|
labels = {
|
||||||
|
"user": _("User to candidate"),
|
||||||
user = AutoCompleteSelectField(
|
}
|
||||||
"users", label=_("User to candidate"), help_text=None, required=True
|
widgets = {
|
||||||
)
|
"program": MarkdownInput,
|
||||||
|
"user": AutoCompleteSelectUser,
|
||||||
|
"role": AutoCompleteSelect,
|
||||||
|
"election_list": AutoCompleteSelect,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
election_id = kwargs.pop("election_id", None)
|
election_id = kwargs.pop("election_id", None)
|
||||||
@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Role
|
model = Role
|
||||||
fields = ["title", "election", "description", "max_choice"]
|
fields = ["title", "election", "description", "max_choice"]
|
||||||
|
widgets = {"election": AutoCompleteSelect}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
election_id = kwargs.pop("election_id", None)
|
election_id = kwargs.pop("election_id", None)
|
||||||
@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ElectionList
|
model = ElectionList
|
||||||
fields = ("title", "election")
|
fields = ("title", "election")
|
||||||
|
widgets = {"election": AutoCompleteSelect}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
election_id = kwargs.pop("election_id", None)
|
election_id = kwargs.pop("election_id", None)
|
||||||
@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm):
|
|||||||
"vote_groups",
|
"vote_groups",
|
||||||
"candidature_groups",
|
"candidature_groups",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
edit_groups = make_ajax_field(
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
Election, "edit_groups", "groups", help_text="", label=_("edit groups")
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
)
|
"vote_groups": AutoCompleteSelectMultipleGroup,
|
||||||
view_groups = make_ajax_field(
|
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
||||||
Election, "view_groups", "groups", help_text="", label=_("view groups")
|
}
|
||||||
)
|
|
||||||
vote_groups = make_ajax_field(
|
|
||||||
Election, "vote_groups", "groups", help_text="", label=_("vote groups")
|
|
||||||
)
|
|
||||||
candidature_groups = make_ajax_field(
|
|
||||||
Election,
|
|
||||||
"candidature_groups",
|
|
||||||
"groups",
|
|
||||||
help_text="",
|
|
||||||
label=_("candidature groups"),
|
|
||||||
)
|
|
||||||
|
|
||||||
start_date = forms.DateTimeField(
|
start_date = forms.DateTimeField(
|
||||||
label=_("Start date"), widget=SelectDateTime, required=True
|
label=_("Start date"), widget=SelectDateTime, required=True
|
||||||
@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
|
|||||||
"""Verify that the selected user is in candidate group."""
|
"""Verify that the selected user is in candidate group."""
|
||||||
obj = form.instance
|
obj = form.instance
|
||||||
obj.election = Election.objects.get(id=self.election.id)
|
obj.election = Election.objects.get(id=self.election.id)
|
||||||
|
obj.user = obj.user if hasattr(obj, "user") else self.request.user
|
||||||
if (obj.election.can_candidate(obj.user)) and (
|
if (obj.election.can_candidate(obj.user)) and (
|
||||||
obj.user == self.request.user or self.can_edit
|
obj.user == self.request.user or self.can_edit
|
||||||
):
|
):
|
||||||
|
@ -25,7 +25,6 @@ import logging
|
|||||||
import math
|
import math
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@ -43,6 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||||||
from haystack.query import RelatedSearchQuerySet
|
from haystack.query import RelatedSearchQuerySet
|
||||||
from honeypot.decorators import check_honeypot
|
from honeypot.decorators import check_honeypot
|
||||||
|
|
||||||
|
from club.widgets.select import AutoCompleteSelectClub
|
||||||
from core.views import (
|
from core.views import (
|
||||||
CanCreateMixin,
|
CanCreateMixin,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -50,7 +50,11 @@ from core.views import (
|
|||||||
CanViewMixin,
|
CanViewMixin,
|
||||||
can_view,
|
can_view,
|
||||||
)
|
)
|
||||||
from core.views.forms import MarkdownInput
|
from core.views.widgets.markdown import MarkdownInput
|
||||||
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelect,
|
||||||
|
AutoCompleteSelectMultipleGroup,
|
||||||
|
)
|
||||||
from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic
|
from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic
|
||||||
|
|
||||||
|
|
||||||
@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm):
|
|||||||
"edit_groups",
|
"edit_groups",
|
||||||
"view_groups",
|
"view_groups",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"owner_club": AutoCompleteSelectClub,
|
||||||
|
}
|
||||||
|
|
||||||
edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="")
|
parent = ForumNameField(
|
||||||
view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="")
|
Forum.objects.all(), widget=AutoCompleteSelect, required=False
|
||||||
parent = ForumNameField(Forum.objects.all())
|
)
|
||||||
|
|
||||||
|
|
||||||
class ForumCreateView(CanCreateMixin, CreateView):
|
class ForumCreateView(CanCreateMixin, CreateView):
|
||||||
|
@ -25,7 +25,7 @@ from django import forms
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import MarkdownInput
|
from core.views.widgets.markdown import MarkdownInput
|
||||||
from pedagogy.models import UV, UVComment, UVCommentReport
|
from pedagogy.models import UV, UVComment, UVCommentReport
|
||||||
|
|
||||||
|
|
||||||
|
12
poetry.lock
generated
12
poetry.lock
generated
@ -520,16 +520,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
|
|||||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||||
bcrypt = ["bcrypt"]
|
bcrypt = ["bcrypt"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-ajax-selects"
|
|
||||||
version = "2.2.1"
|
|
||||||
description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete."
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
files = [
|
|
||||||
{file = "django-ajax-selects-2.2.1.tar.gz", hash = "sha256:996ffb38dff1a621b358613afdf2681dbf261e5976da3c30a75e9b08fd81a887"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-countries"
|
name = "django-countries"
|
||||||
version = "7.6.1"
|
version = "7.6.1"
|
||||||
@ -2691,4 +2681,4 @@ filelock = ">=3.4"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0"
|
content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2"
|
||||||
|
@ -30,7 +30,6 @@ django-jinja = "^2.11"
|
|||||||
cryptography = "^43.0.0"
|
cryptography = "^43.0.0"
|
||||||
django-phonenumber-field = "^8.0.0"
|
django-phonenumber-field = "^8.0.0"
|
||||||
phonenumbers = "^8.13"
|
phonenumbers = "^8.13"
|
||||||
django-ajax-selects = "^2.2.1"
|
|
||||||
reportlab = "^4.2"
|
reportlab = "^4.2"
|
||||||
django-haystack = "^3.2.1"
|
django-haystack = "^3.2.1"
|
||||||
xapian-haystack = "^3.0.1"
|
xapian-haystack = "^3.0.1"
|
||||||
|
@ -23,7 +23,6 @@
|
|||||||
#
|
#
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -35,6 +34,7 @@ from django.views.generic.edit import FormView
|
|||||||
|
|
||||||
from core.models import OperationLog, SithFile, User
|
from core.models import OperationLog, SithFile, User
|
||||||
from core.views import CanEditPropMixin
|
from core.views import CanEditPropMixin
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectUser
|
||||||
from counter.models import Customer
|
from counter.models import Customer
|
||||||
from forum.models import ForumMessageMeta
|
from forum.models import ForumMessageMeta
|
||||||
|
|
||||||
@ -156,17 +156,29 @@ def delete_all_forum_user_messages(
|
|||||||
|
|
||||||
|
|
||||||
class MergeForm(forms.Form):
|
class MergeForm(forms.Form):
|
||||||
user1 = AutoCompleteSelectField(
|
user1 = forms.ModelChoiceField(
|
||||||
"users", label=_("User that will be kept"), help_text=None, required=True
|
label=_("User that will be kept"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
user2 = AutoCompleteSelectField(
|
user2 = forms.ModelChoiceField(
|
||||||
"users", label=_("User that will be deleted"), help_text=None, required=True
|
label=_("User that will be deleted"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SelectUserForm(forms.Form):
|
class SelectUserForm(forms.Form):
|
||||||
user = AutoCompleteSelectField(
|
user = forms.ModelChoiceField(
|
||||||
"users", label=_("User to be selected"), help_text=None, required=True
|
label=_("User to be selected"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.conf import settings
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated
|
|||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
from pydantic import NonNegativeInt
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
from core.api_permissions import CanView, IsInGroup, IsRoot
|
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||||
from core.models import Notification, User
|
from core.models import Notification, User
|
||||||
from sas.models import PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
from sas.schemas import (
|
from sas.schemas import (
|
||||||
|
AlbumSchema,
|
||||||
IdentifiedUserSchema,
|
IdentifiedUserSchema,
|
||||||
ModerationRequestSchema,
|
ModerationRequestSchema,
|
||||||
PictureFilterSchema,
|
PictureFilterSchema,
|
||||||
@ -22,6 +26,18 @@ from sas.schemas import (
|
|||||||
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
|
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/sas/album")
|
||||||
|
class AlbumController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"/search",
|
||||||
|
response=PaginatedResponseSchema[AlbumSchema],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_album(self, search: Annotated[str, MinLen(1)]):
|
||||||
|
return Album.objects.filter(name__icontains=search)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/sas/picture")
|
@api_controller("/sas/picture")
|
||||||
class PicturesController(ControllerBase):
|
class PicturesController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
|
26
sas/forms.py
26
sas/forms.py
@ -1,14 +1,14 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ajax_select import make_ajax_field
|
|
||||||
from ajax_select.fields import AutoCompleteSelectMultipleField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views import MultipleImageField
|
from core.views import MultipleImageField
|
||||||
from core.views.forms import SelectDate
|
from core.views.forms import SelectDate
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
from core.views.widgets.select import AutoCompleteSelectMultipleGroup
|
||||||
|
from sas.models import Album, Picture, PictureModerationRequest
|
||||||
|
from sas.widgets.select import AutoCompleteSelectAlbum
|
||||||
|
|
||||||
|
|
||||||
class SASForm(forms.Form):
|
class SASForm(forms.Form):
|
||||||
@ -62,34 +62,24 @@ class SASForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RelationForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = PeoplePictureRelation
|
|
||||||
fields = ["picture"]
|
|
||||||
widgets = {"picture": forms.HiddenInput}
|
|
||||||
|
|
||||||
users = AutoCompleteSelectMultipleField(
|
|
||||||
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PictureEditForm(forms.ModelForm):
|
class PictureEditForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Picture
|
model = Picture
|
||||||
fields = ["name", "parent"]
|
fields = ["name", "parent"]
|
||||||
|
widgets = {"parent": AutoCompleteSelectAlbum}
|
||||||
parent = make_ajax_field(Picture, "parent", "files", help_text="")
|
|
||||||
|
|
||||||
|
|
||||||
class AlbumEditForm(forms.ModelForm):
|
class AlbumEditForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
fields = ["name", "date", "file", "parent", "edit_groups"]
|
fields = ["name", "date", "file", "parent", "edit_groups"]
|
||||||
|
widgets = {
|
||||||
|
"parent": AutoCompleteSelectAlbum,
|
||||||
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
}
|
||||||
|
|
||||||
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
||||||
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
||||||
parent = make_ajax_field(Album, "parent", "files", help_text="")
|
|
||||||
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
|
|
||||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,11 +1,24 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import FilterSchema, ModelSchema, Schema
|
from ninja import FilterSchema, ModelSchema, Schema
|
||||||
from pydantic import Field, NonNegativeInt
|
from pydantic import Field, NonNegativeInt
|
||||||
|
|
||||||
from core.schemas import SimpleUserSchema, UserProfileSchema
|
from core.schemas import SimpleUserSchema, UserProfileSchema
|
||||||
from sas.models import Picture, PictureModerationRequest
|
from sas.models import Album, Picture, PictureModerationRequest
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Album
|
||||||
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
path: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_path(obj: Album) -> str:
|
||||||
|
return str(Path(obj.get_parent_path()) / obj.name)
|
||||||
|
|
||||||
|
|
||||||
class PictureFilterSchema(FilterSchema):
|
class PictureFilterSchema(FilterSchema):
|
||||||
|
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)
|
} as PicturesFetchPicturesData)
|
||||||
).map(PictureWithIdentifications.fromPicture);
|
).map(PictureWithIdentifications.fromPicture);
|
||||||
this.selector = this.$refs.search;
|
this.selector = this.$refs.search;
|
||||||
this.selector.filter = (users: UserProfileSchema[]) => {
|
this.selector.setFilter((users: UserProfileSchema[]) => {
|
||||||
const resp: UserProfileSchema[] = [];
|
const resp: UserProfileSchema[] = [];
|
||||||
const ids = [
|
const ids = [
|
||||||
...(this.currentPicture.identifications || []).map(
|
...(this.currentPicture.identifications || []).map(
|
||||||
@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
};
|
});
|
||||||
this.currentPicture = this.pictures.find(
|
this.currentPicture = this.pictures.find(
|
||||||
(i: PictureSchema) => i.id === config.firstPictureId,
|
(i: PictureSchema) => i.id === config.firstPictureId,
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('webpack/ajax-select-index.css') }}">
|
<link defer rel="stylesheet" href="{{ static('webpack/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
|
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script>
|
<script defer src="{{ static('webpack/core/components/ajax-select-index.ts') }}"></script>
|
||||||
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
|
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
@ -157,12 +158,12 @@
|
|||||||
<h5>{% trans %}People{% endtrans %}</h5>
|
<h5>{% trans %}People{% endtrans %}</h5>
|
||||||
{% if user.was_subscribed %}
|
{% if user.was_subscribed %}
|
||||||
<form @submit.prevent="submitIdentification" x-show="!!selector">
|
<form @submit.prevent="submitIdentification" x-show="!!selector">
|
||||||
<ajax-select
|
<user-ajax-select
|
||||||
x-ref="search"
|
x-ref="search"
|
||||||
multiple
|
multiple
|
||||||
data-delay="300"
|
delay="300"
|
||||||
data-placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
|
placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
|
||||||
></ajax-select>
|
></user-ajax-select>
|
||||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
26
sas/widgets/select.py
Normal file
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",
|
"honeypot",
|
||||||
"django_jinja",
|
"django_jinja",
|
||||||
"ninja_extra",
|
"ninja_extra",
|
||||||
"ajax_select",
|
|
||||||
"haystack",
|
"haystack",
|
||||||
"captcha",
|
"captcha",
|
||||||
"core",
|
"core",
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from ajax_select import urls as ajax_select_urls
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
@ -59,7 +58,6 @@ urlpatterns = [
|
|||||||
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
|
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
|
||||||
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
|
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("ajax_select/", include(ajax_select_urls)),
|
|
||||||
path("i18n/", include("django.conf.urls.i18n")),
|
path("i18n/", include("django.conf.urls.i18n")),
|
||||||
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
|
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
|
||||||
path("captcha/", include("captcha.urls")),
|
path("captcha/", include("captcha.urls")),
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
@ -25,6 +24,7 @@ from django.views.generic.edit import CreateView, FormView
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDate, SelectDateTime
|
from core.views.forms import SelectDate, SelectDateTime
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectUser
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
@ -43,11 +43,11 @@ class SubscriptionForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Subscription
|
model = Subscription
|
||||||
fields = ["member", "subscription_type", "payment_method", "location"]
|
fields = ["member", "subscription_type", "payment_method", "location"]
|
||||||
|
widgets = {"member": AutoCompleteSelectUser}
|
||||||
member = AutoCompleteSelectField("users", required=False, help_text=None)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["member"].required = False
|
||||||
self.fields |= forms.fields_for_model(
|
self.fields |= forms.fields_for_model(
|
||||||
User,
|
User,
|
||||||
fields=["first_name", "last_name", "email", "date_of_birth"],
|
fields=["first_name", "last_name", "email", "date_of_birth"],
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from ajax_select.fields import AutoCompleteSelectField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@ -49,6 +48,7 @@ from core.views import (
|
|||||||
TabedViewMixin,
|
TabedViewMixin,
|
||||||
)
|
)
|
||||||
from core.views.forms import SelectDate
|
from core.views.forms import SelectDate
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectUser
|
||||||
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
|
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
|
||||||
|
|
||||||
|
|
||||||
@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class AddUserForm(forms.Form):
|
class AddUserForm(forms.Form):
|
||||||
user = AutoCompleteSelectField(
|
user = forms.ModelChoiceField(
|
||||||
"users", required=True, label=_("Select user"), help_text=None
|
label=_("Select user"),
|
||||||
|
help_text=None,
|
||||||
|
required=True,
|
||||||
|
widget=AutoCompleteSelectUser,
|
||||||
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ module.exports = {
|
|||||||
.sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)")
|
.sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)")
|
||||||
.reduce((obj, el) => {
|
.reduce((obj, el) => {
|
||||||
// We include the path inside the webpack folder in the name
|
// We include the path inside the webpack folder in the name
|
||||||
const relativePath = [];
|
let relativePath = [];
|
||||||
const fullPath = path.parse(el);
|
const fullPath = path.parse(el);
|
||||||
for (const dir of fullPath.dir.split("/").reverse()) {
|
for (const dir of fullPath.dir.split("/").reverse()) {
|
||||||
if (dir === "webpack") {
|
if (dir === "webpack") {
|
||||||
@ -18,6 +18,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
relativePath.push(dir);
|
relativePath.push(dir);
|
||||||
}
|
}
|
||||||
|
// We collected folders in reverse order, we put them back in the original order
|
||||||
|
relativePath = relativePath.reverse();
|
||||||
relativePath.push(fullPath.name);
|
relativePath.push(fullPath.name);
|
||||||
obj[relativePath.join("/")] = `./${el}`;
|
obj[relativePath.join("/")] = `./${el}`;
|
||||||
return obj;
|
return obj;
|
||||||
|
Loading…
Reference in New Issue
Block a user