mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-09 19:40:19 +00:00
35
core/api.py
35
core/api.py
@ -11,11 +11,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from club.models import Mailing
|
||||
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
|
||||
from core.models import User
|
||||
from core.api_permissions import (
|
||||
CanAccessLookup,
|
||||
CanView,
|
||||
)
|
||||
from core.models import Group, SithFile, User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
GroupSchema,
|
||||
MarkdownSchema,
|
||||
SithFileSchema,
|
||||
UserFamilySchema,
|
||||
UserFilterSchema,
|
||||
UserProfileSchema,
|
||||
@ -44,7 +49,7 @@ class MailingListController(ControllerBase):
|
||||
return data
|
||||
|
||||
|
||||
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
|
||||
@api_controller("/user", permissions=[CanAccessLookup])
|
||||
class UserController(ControllerBase):
|
||||
@route.get("", response=list[UserProfileSchema])
|
||||
def fetch_profiles(self, pks: Query[set[int]]):
|
||||
@ -62,6 +67,30 @@ class UserController(ControllerBase):
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/file")
|
||||
class SithFileController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SithFileSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||
|
||||
|
||||
@api_controller("/group")
|
||||
class GroupController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[GroupSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||
return Group.objects.filter(name__icontains=search).values()
|
||||
|
||||
|
||||
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
||||
DEFAULT_DEPTH = 4
|
||||
|
||||
|
@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission):
|
||||
"""Check that a user is logged in a counter."""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
if "/counter/" not in request.META["HTTP_REFERER"]:
|
||||
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
|
||||
return False
|
||||
token = request.session.get("counter_token")
|
||||
if not token:
|
||||
return False
|
||||
return Counter.objects.filter(token=token).exists()
|
||||
|
||||
|
||||
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
|
||||
|
141
core/lookups.py
141
core/lookups.py
@ -1,141 +0,0 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from ajax_select import LookupChannel, register
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from accounting.models import ClubAccount, Company
|
||||
from club.models import Club
|
||||
from core.models import Group, SithFile, User
|
||||
from core.views.site import search_user
|
||||
from counter.models import Counter, Customer, Product
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
class RightManagedLookupChannel(LookupChannel):
|
||||
def check_auth(self, request):
|
||||
if not request.user.was_subscribed and not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
@register("users")
|
||||
class UsersLookup(RightManagedLookupChannel):
|
||||
model = User
|
||||
|
||||
def get_query(self, q, request):
|
||||
return search_user(q)
|
||||
|
||||
def format_match(self, obj):
|
||||
return obj.get_mini_item()
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.get_display_name()
|
||||
|
||||
|
||||
@register("customers")
|
||||
class CustomerLookup(RightManagedLookupChannel):
|
||||
model = Customer
|
||||
|
||||
def get_query(self, q, request):
|
||||
return list(Customer.objects.filter(user__in=search_user(q)))
|
||||
|
||||
def format_match(self, obj):
|
||||
return obj.user.get_mini_item()
|
||||
|
||||
def format_item_display(self, obj):
|
||||
return f"{obj.user.get_display_name()} ({obj.account_id})"
|
||||
|
||||
|
||||
@register("groups")
|
||||
class GroupsLookup(RightManagedLookupChannel):
|
||||
model = Group
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
def format_match(self, obj):
|
||||
return obj.name
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.name
|
||||
|
||||
|
||||
@register("clubs")
|
||||
class ClubLookup(RightManagedLookupChannel):
|
||||
model = Club
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
def format_match(self, obj):
|
||||
return obj.name
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.name
|
||||
|
||||
|
||||
@register("counters")
|
||||
class CountersLookup(RightManagedLookupChannel):
|
||||
model = Counter
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.name
|
||||
|
||||
|
||||
@register("products")
|
||||
class ProductsLookup(RightManagedLookupChannel):
|
||||
model = Product
|
||||
|
||||
def get_query(self, q, request):
|
||||
return (
|
||||
self.model.objects.filter(name__icontains=q)
|
||||
| self.model.objects.filter(code__icontains=q)
|
||||
).filter(archived=False)[:50]
|
||||
|
||||
def format_item_display(self, item):
|
||||
return "%s (%s)" % (item.name, item.code)
|
||||
|
||||
|
||||
@register("files")
|
||||
class SithFileLookup(RightManagedLookupChannel):
|
||||
model = SithFile
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
|
||||
@register("club_accounts")
|
||||
class ClubAccountLookup(RightManagedLookupChannel):
|
||||
model = ClubAccount
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.name
|
||||
|
||||
|
||||
@register("companies")
|
||||
class CompaniesLookup(RightManagedLookupChannel):
|
||||
model = Company
|
||||
|
||||
def get_query(self, q, request):
|
||||
return self.model.objects.filter(name__icontains=q)[:50]
|
||||
|
||||
def format_item_display(self, item):
|
||||
return item.name
|
@ -1,3 +1,4 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import MinLen
|
||||
@ -8,7 +9,7 @@ from haystack.query import SearchQuerySet
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from core.models import User
|
||||
from core.models import Group, SithFile, User
|
||||
|
||||
|
||||
class SimpleUserSchema(ModelSchema):
|
||||
@ -45,6 +46,24 @@ class UserProfileSchema(ModelSchema):
|
||||
return obj.profile_pict.get_download_url()
|
||||
|
||||
|
||||
class SithFileSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = SithFile
|
||||
fields = ["id", "name"]
|
||||
|
||||
path: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_path(obj: SithFile) -> str:
|
||||
return str(Path(obj.get_parent_path()) / obj.name)
|
||||
|
||||
|
||||
class GroupSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class UserFilterSchema(FilterSchema):
|
||||
search: Annotated[str, MinLen(1)]
|
||||
exclude: list[int] | None = Field(
|
||||
|
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 {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
|
@ -1,93 +0,0 @@
|
||||
import "tom-select/dist/css/tom-select.css";
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||
import TomSelect from "tom-select";
|
||||
import type { TomItem, TomLoadCallback, TomOption } from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
import { type UserProfileSchema, userSearchUsers } from "#openapi";
|
||||
|
||||
@registerComponent("ajax-select")
|
||||
export class AjaxSelect extends inheritHtmlElement("select") {
|
||||
public widget: TomSelect;
|
||||
public filter?: <T>(items: T[]) => T[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
this.loadTomSelect();
|
||||
});
|
||||
}
|
||||
|
||||
loadTomSelect() {
|
||||
const minCharNumberForSearch = 2;
|
||||
let maxItems = 1;
|
||||
|
||||
if (this.node.multiple) {
|
||||
maxItems = Number.parseInt(this.node.dataset.max) ?? null;
|
||||
}
|
||||
|
||||
this.widget = new TomSelect(this.node, {
|
||||
hideSelected: true,
|
||||
diacritics: true,
|
||||
duplicates: false,
|
||||
maxItems: maxItems,
|
||||
loadThrottle: Number.parseInt(this.node.dataset.delay) ?? null,
|
||||
valueField: "id",
|
||||
labelField: "display_name",
|
||||
searchField: ["display_name", "nick_name", "first_name", "last_name"],
|
||||
placeholder: this.node.dataset.placeholder ?? "",
|
||||
shouldLoad: (query: string) => {
|
||||
return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters
|
||||
},
|
||||
load: (query: string, callback: TomLoadCallback) => {
|
||||
userSearchUsers({
|
||||
query: {
|
||||
search: query,
|
||||
},
|
||||
}).then((response) => {
|
||||
if (response.data) {
|
||||
if (this.filter) {
|
||||
callback(this.filter(response.data.results), []);
|
||||
} else {
|
||||
callback(response.data.results, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
callback([], []);
|
||||
});
|
||||
},
|
||||
render: {
|
||||
option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
|
||||
return `<div class="select-item">
|
||||
<img
|
||||
src="${sanitize(item.profile_pict)}"
|
||||
alt="${sanitize(item.display_name)}"
|
||||
onerror="this.src = '/static/core/img/unknown.jpg'"
|
||||
/>
|
||||
<span class="select-item-text">${sanitize(item.display_name)}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
|
||||
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: that's how it's defined
|
||||
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
|
||||
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: minCharNumberForSearch - data.input.length }, true)}</div>`;
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: that's how it's defined
|
||||
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
|
||||
return `<div class="no-results">${gettext("No results found")}</div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Allow removing selected items by clicking on them
|
||||
this.widget.on("item_select", (item: TomItem) => {
|
||||
this.widget.removeItem(item);
|
||||
});
|
||||
// Remove typed text once an item has been selected
|
||||
this.widget.on("item_add", () => {
|
||||
this.widget.setTextboxValue("");
|
||||
});
|
||||
}
|
||||
}
|
183
core/static/webpack/core/components/ajax-select-base.ts
Normal file
183
core/static/webpack/core/components/ajax-select-base.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { inheritHtmlElement } from "#core:utils/web-components";
|
||||
import TomSelect from "tom-select";
|
||||
import type {
|
||||
RecursivePartial,
|
||||
TomLoadCallback,
|
||||
TomOption,
|
||||
TomSettings,
|
||||
} from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
|
||||
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
||||
static observedAttributes = [
|
||||
"delay",
|
||||
"placeholder",
|
||||
"max",
|
||||
"min-characters-for-search",
|
||||
];
|
||||
public widget: TomSelect;
|
||||
|
||||
protected minCharNumberForSearch = 0;
|
||||
protected delay: number | null = null;
|
||||
protected placeholder = "";
|
||||
protected max: number | null = null;
|
||||
|
||||
protected attributeChangedCallback(
|
||||
name: string,
|
||||
_oldValue?: string,
|
||||
newValue?: string,
|
||||
) {
|
||||
switch (name) {
|
||||
case "delay": {
|
||||
this.delay = Number.parseInt(newValue) ?? null;
|
||||
break;
|
||||
}
|
||||
case "placeholder": {
|
||||
this.placeholder = newValue ?? "";
|
||||
break;
|
||||
}
|
||||
case "max": {
|
||||
this.max = Number.parseInt(newValue) ?? null;
|
||||
break;
|
||||
}
|
||||
case "min-characters-for-search": {
|
||||
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.widget = new TomSelect(this.node, this.tomSelectSettings());
|
||||
this.attachBehaviors();
|
||||
}
|
||||
|
||||
protected shouldLoad(query: string) {
|
||||
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
|
||||
}
|
||||
|
||||
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||
return {
|
||||
plugins: {
|
||||
// biome-ignore lint/style/useNamingConvention: this is required by the api
|
||||
remove_button: {
|
||||
title: gettext("Remove"),
|
||||
},
|
||||
},
|
||||
persist: false,
|
||||
maxItems: this.node.multiple ? this.max : 1,
|
||||
closeAfterSelect: true,
|
||||
loadThrottle: this.delay,
|
||||
placeholder: this.placeholder,
|
||||
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
|
||||
render: {
|
||||
option: (item: TomOption, sanitize: typeof escape_html) => {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.text)}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (item: TomOption, sanitize: typeof escape_html) => {
|
||||
return `<span>${sanitize(item.text)}</span>`;
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: that's how it's defined
|
||||
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
|
||||
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: that's how it's defined
|
||||
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
|
||||
return `<div class="no-results">${gettext("No results found")}</div>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected attachBehaviors() {
|
||||
/* Called once the widget has been initialized */
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
||||
protected minCharNumberForSearch = 2;
|
||||
|
||||
protected abstract valueField: string;
|
||||
protected abstract labelField: string;
|
||||
protected abstract searchField: string[];
|
||||
|
||||
protected abstract renderOption(
|
||||
item: TomOption,
|
||||
sanitize: typeof escape_html,
|
||||
): string;
|
||||
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
|
||||
protected abstract search(query: string): Promise<TomOption[]>;
|
||||
|
||||
private initialValues: TomOption[] = [];
|
||||
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
protected shouldLoad(query: string) {
|
||||
const resp = super.shouldLoad(query);
|
||||
/* Force order sync with backend if no client side filtering is set */
|
||||
if (!resp && this.searchField.length === 0) {
|
||||
this.widget.clearOptions();
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
protected async loadFunction(query: string, callback: TomLoadCallback) {
|
||||
/* Force order sync with backend if no client side filtering is set */
|
||||
if (this.searchField.length === 0) {
|
||||
this.widget.clearOptions();
|
||||
}
|
||||
|
||||
const resp = await this.search(query);
|
||||
|
||||
if (this.filter) {
|
||||
callback(this.filter(resp), []);
|
||||
} else {
|
||||
callback(resp, []);
|
||||
}
|
||||
}
|
||||
|
||||
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||
return {
|
||||
...super.tomSelectSettings(),
|
||||
hideSelected: true,
|
||||
diacritics: true,
|
||||
duplicates: false,
|
||||
valueField: this.valueField,
|
||||
labelField: this.labelField,
|
||||
searchField: this.searchField,
|
||||
load: (query: string, callback: TomLoadCallback) =>
|
||||
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
|
||||
render: {
|
||||
...super.tomSelectSettings().render,
|
||||
option: this.renderOption,
|
||||
item: this.renderItem,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
|
||||
const initial = this.querySelector("slot[name='initial']")?.textContent;
|
||||
this.initialValues = initial ? JSON.parse(initial) : [];
|
||||
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
protected attachBehaviors() {
|
||||
super.attachBehaviors();
|
||||
|
||||
// Gather selected options, they must be added with slots like `<slot>json</slot>`
|
||||
for (const value of this.initialValues) {
|
||||
this.widget.addOption(value, false);
|
||||
this.widget.addItem(value[this.valueField]);
|
||||
}
|
||||
}
|
||||
}
|
100
core/static/webpack/core/components/ajax-select-index.ts
Normal file
100
core/static/webpack/core/components/ajax-select-index.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import "tom-select/dist/css/tom-select.default.css";
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
import type { TomOption } from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
import {
|
||||
type GroupSchema,
|
||||
type SithFileSchema,
|
||||
type UserProfileSchema,
|
||||
groupSearchGroup,
|
||||
sithfileSearchFiles,
|
||||
userSearchUsers,
|
||||
} from "#openapi";
|
||||
|
||||
import {
|
||||
AjaxSelect,
|
||||
AutoCompleteSelectBase,
|
||||
} from "#core:core/components/ajax-select-base";
|
||||
|
||||
@registerComponent("autocomplete-select")
|
||||
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
|
||||
|
||||
@registerComponent("user-ajax-select")
|
||||
export class UserAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "display_name";
|
||||
protected searchField: string[] = []; // Disable local search filter and rely on tested backend
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await userSearchUsers({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<img
|
||||
src="${sanitize(item.profile_pict)}"
|
||||
alt="${sanitize(item.display_name)}"
|
||||
onerror="this.src = '/static/core/img/unknown.jpg'"
|
||||
/>
|
||||
<span class="select-item-text">${sanitize(item.display_name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.display_name)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("group-ajax-select")
|
||||
export class GroupsAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "name";
|
||||
protected searchField = ["name"];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await groupSearchGroup({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: GroupSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: GroupSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("sith-file-ajax-select")
|
||||
export class SithFileAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "path";
|
||||
protected searchField = ["path", "name"];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await sithfileSearchFiles({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: SithFileSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.path)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: SithFileSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.path)}</span>`;
|
||||
}
|
||||
}
|
@ -13,16 +13,22 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
element: textarea,
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
|
||||
preview.innerHTML = (
|
||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||
).data as string;
|
||||
previewRender: (plainText: string, preview: MarkdownInput) => {
|
||||
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||
const func = async (
|
||||
plainText: string,
|
||||
preview: MarkdownInput,
|
||||
): Promise<null> => {
|
||||
preview.innerHTML = (
|
||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||
).data as string;
|
||||
return null;
|
||||
};
|
||||
func(plainText, preview);
|
||||
return null;
|
||||
};
|
||||
func(plainText, preview);
|
||||
return null;
|
||||
}, 300),
|
||||
}, 300)(plainText, preview);
|
||||
},
|
||||
forceSync: true, // Avoid validation error on generic create view
|
||||
toolbar: [
|
||||
{
|
||||
@ -185,8 +191,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
|
||||
@registerComponent("markdown-input")
|
||||
class MarkdownInput extends inheritHtmlElement("textarea") {
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node));
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
loadEasyMde(this.node);
|
||||
}
|
||||
}
|
33
core/static/webpack/core/components/include-index.ts
Normal file
33
core/static/webpack/core/components/include-index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||
|
||||
/**
|
||||
* Web component used to import css files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("link-once")
|
||||
export class LinkOnce extends inheritHtmlElement("link") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
||||
const href = this.node.attributes.getNamedItem("href").nodeValue;
|
||||
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web component used to import javascript files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("script-once")
|
||||
export class ScriptOnce extends inheritHtmlElement("script") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
// We get src from node.attributes instead of node.src to avoid getting the domain part
|
||||
const src = this.node.attributes.getNamedItem("src").nodeValue;
|
||||
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
|
||||
import { client } from "#openapi";
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
interface PaginatedRequest {
|
||||
export interface PaginatedRequest {
|
||||
query?: {
|
||||
page?: number;
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
|
@ -30,8 +30,7 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
||||
return class Inherited extends HTMLElement {
|
||||
protected node: HTMLElementTagNameMap[K];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
connectedCallback(autoAddNode?: boolean) {
|
||||
this.node = document.createElement(tagName);
|
||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||
for (const attr of this.attributes) {
|
||||
@ -44,7 +43,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
||||
this.removeAttributeNode(attr);
|
||||
this.node.setAttributeNode(attr);
|
||||
}
|
||||
this.appendChild(this.node);
|
||||
|
||||
this.node.innerHTML = this.innerHTML;
|
||||
this.innerHTML = "";
|
||||
|
||||
// Automatically add node to DOM if autoAddNode is true or unspecified
|
||||
if (autoAddNode === undefined || autoAddNode) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -7,7 +7,6 @@
|
||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||
@ -21,6 +20,8 @@
|
||||
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
|
||||
|
||||
<script src="{{ url('javascript-catalog') }}"></script>
|
||||
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
|
||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
||||
@ -301,8 +302,6 @@
|
||||
{% endif %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{{ static('ajax_select/js/ajax_select.js') }}"></script>
|
||||
<script src="{{ url('javascript-catalog') }}"></script>
|
||||
<script>
|
||||
function showMenu() {
|
||||
let navbar = document.getElementById("navbar-content");
|
||||
|
23
core/templates/core/widgets/autocomplete_select.jinja
Normal file
23
core/templates/core/widgets/autocomplete_select.jinja
Normal file
@ -0,0 +1,23 @@
|
||||
{% for js in statics.js %}
|
||||
<script-once src="{{ js }}" defer></script-once>
|
||||
{% endfor %}
|
||||
{% for css in statics.css %}
|
||||
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
||||
{% endfor %}
|
||||
|
||||
<{{ component }} name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% for group_name, group_choices, group_index in widget.optgroups %}
|
||||
{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">
|
||||
{% endif %}
|
||||
{% for widget in group_choices %}
|
||||
{% include widget.template_name %}
|
||||
{% endfor %}
|
||||
{% if group_name %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if initial %}
|
||||
<slot style="display:none" name="initial">{{ initial }}</slot>
|
||||
{% endif %}
|
||||
</{{ component }}>
|
@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
<script-once src="{{ statics.js }}" defer></script-once>
|
||||
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
||||
|
||||
{# The easymde script can be included twice, it's safe in the code #}
|
||||
<script src="{{ statics.js }}" defer> </script>
|
||||
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
|
||||
</div>
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
|
||||
</div>
|
||||
|
@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin
|
||||
# This file contains all the views that concern the page model
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
from ajax_select import make_ajax_field
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@ -39,6 +38,11 @@ from core.views import (
|
||||
CanViewMixin,
|
||||
can_view,
|
||||
)
|
||||
from core.views.widgets.select import (
|
||||
AutoCompleteSelectMultipleGroup,
|
||||
AutoCompleteSelectSithFile,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SithFile
|
||||
fields = ["parent", "owner", "edit_groups", "view_groups"]
|
||||
widgets = {
|
||||
"parent": AutoCompleteSelectSithFile,
|
||||
"owner": AutoCompleteSelectUser,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||
}
|
||||
|
||||
parent = make_ajax_field(SithFile, "parent", "files", help_text="")
|
||||
edit_groups = make_ajax_field(
|
||||
SithFile, "edit_groups", "groups", help_text="", label=_("edit group")
|
||||
)
|
||||
view_groups = make_ajax_field(
|
||||
SithFile, "view_groups", "groups", help_text="", label=_("view group")
|
||||
)
|
||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||
|
||||
|
||||
|
@ -23,20 +23,16 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
from ajax_select import make_ajax_field
|
||||
from ajax_select.fields import AutoCompleteSelectField
|
||||
from captcha.fields import CaptchaField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.forms import (
|
||||
CheckboxSelectMultiple,
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
Textarea,
|
||||
TextInput,
|
||||
)
|
||||
from django.utils.translation import gettext
|
||||
@ -47,6 +43,12 @@ from PIL import Image
|
||||
from antispam.forms import AntiSpamEmailField
|
||||
from core.models import Gift, Page, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from core.views.widgets.select import (
|
||||
AutoCompleteSelect,
|
||||
AutoCompleteSelectGroup,
|
||||
AutoCompleteSelectMultipleGroup,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
|
||||
# Widgets
|
||||
|
||||
@ -65,19 +67,6 @@ class SelectDate(DateInput):
|
||||
super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
|
||||
|
||||
|
||||
class MarkdownInput(Textarea):
|
||||
template_name = "core/widgets/markdown_textarea.jinja"
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
|
||||
context["statics"] = {
|
||||
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
|
||||
"css": staticfiles_storage.url("webpack/easymde-index.css"),
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
class NFCTextInput(TextInput):
|
||||
template_name = "core/widgets/nfc.jinja"
|
||||
|
||||
@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form):
|
||||
],
|
||||
label=_("Add"),
|
||||
)
|
||||
user = AutoCompleteSelectField(
|
||||
"users", required=True, label=_("Select user"), help_text=""
|
||||
user = forms.ModelChoiceField(
|
||||
label=_("Select user"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, *args, user: User, **kwargs):
|
||||
@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
||||
|
||||
edit_groups = make_ajax_field(
|
||||
Page, "edit_groups", "groups", help_text="", label=_("edit groups")
|
||||
)
|
||||
view_groups = make_ajax_field(
|
||||
Page, "view_groups", "groups", help_text="", label=_("view groups")
|
||||
)
|
||||
widgets = {
|
||||
"parent": AutoCompleteSelect,
|
||||
"owner_group": AutoCompleteSelectGroup,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||
}
|
||||
|
||||
def __init__(self, *arg, **kwargs):
|
||||
super().__init__(*arg, **kwargs)
|
||||
@ -372,13 +364,12 @@ class PageForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
|
||||
|
||||
edit_groups = make_ajax_field(
|
||||
Page, "edit_groups", "groups", help_text="", label=_("edit groups")
|
||||
)
|
||||
view_groups = make_ajax_field(
|
||||
Page, "view_groups", "groups", help_text="", label=_("view groups")
|
||||
)
|
||||
widgets = {
|
||||
"parent": AutoCompleteSelect,
|
||||
"owner_group": AutoCompleteSelectGroup,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -15,7 +15,6 @@
|
||||
|
||||
"""Views to manage Groups."""
|
||||
|
||||
from ajax_select.fields import AutoCompleteSelectMultipleField
|
||||
from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -24,6 +23,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
|
||||
from core.models import RealGroup, User
|
||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
||||
from core.views.widgets.select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
)
|
||||
|
||||
# Forms
|
||||
|
||||
@ -34,6 +36,15 @@ class EditMembersForm(forms.Form):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.current_users = kwargs.pop("users", [])
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields["users_added"] = forms.ModelMultipleChoiceField(
|
||||
label=_("Users to add to group"),
|
||||
help_text=_("Search users to add (one or more)."),
|
||||
required=False,
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
queryset=User.objects.exclude(id__in=self.current_users).all(),
|
||||
)
|
||||
|
||||
self.fields["users_removed"] = forms.ModelMultipleChoiceField(
|
||||
User.objects.filter(id__in=self.current_users).all(),
|
||||
label=_("Users to remove from group"),
|
||||
@ -41,31 +52,6 @@ class EditMembersForm(forms.Form):
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
users_added = AutoCompleteSelectMultipleField(
|
||||
"users",
|
||||
label=_("Users to add to group"),
|
||||
help_text=_("Search users to add (one or more)."),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def clean_users_added(self):
|
||||
"""Check that the user is not trying to add an user already in the group."""
|
||||
cleaned_data = super().clean()
|
||||
users_added = cleaned_data.get("users_added", None)
|
||||
if not users_added:
|
||||
return users_added
|
||||
|
||||
current_users = [
|
||||
str(id_) for id_ in self.current_users.values_list("id", flat=True)
|
||||
]
|
||||
for user in users_added:
|
||||
if user in current_users:
|
||||
raise forms.ValidationError(
|
||||
_("You can not add the same user twice"), code="invalid"
|
||||
)
|
||||
|
||||
return users_added
|
||||
|
||||
|
||||
# Views
|
||||
|
||||
@ -110,10 +96,12 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
|
||||
data = form.clean()
|
||||
group = self.get_object()
|
||||
for user in data["users_removed"]:
|
||||
group.users.remove(user)
|
||||
for user in data["users_added"]:
|
||||
group.users.add(user)
|
||||
if data["users_removed"]:
|
||||
for user in data["users_removed"]:
|
||||
group.users.remove(user)
|
||||
if data["users_added"]:
|
||||
for user in data["users_added"]:
|
||||
group.users.add(user)
|
||||
group.save()
|
||||
|
||||
return resp
|
||||
|
@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
|
||||
from core.models import LockError, Page, PageRev
|
||||
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
|
||||
from core.views.forms import MarkdownInput, PageForm, PagePropForm
|
||||
from core.views.forms import PageForm, PagePropForm
|
||||
from core.views.widgets.markdown import MarkdownInput
|
||||
|
||||
|
||||
class CanEditPagePropMixin(CanEditPropMixin):
|
||||
|
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])
|
Reference in New Issue
Block a user