commit 05358d39288bc410525d6fdad4e7fab57a7c9d8a Author: paulmataruso Date: Sat Jan 25 16:46:21 2025 +0000 First Upload diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d636ae0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.10.3-alpine + +WORKDIR /rustdesk-api-server +ADD . /rustdesk-api-server + +# 安装系统依赖 +RUN apk add --no-cache \ + gcc \ + musl-dev \ + mariadb-connector-c-dev \ + pkgconfig + +RUN set -ex \ + && pip install --no-cache-dir --disable-pip-version-check -r requirements.txt \ + && rm -rf /var/cache/apk/* \ + && cp -r ./db ./db_bak + +ENV HOST="0.0.0.0" +ENV TZ="Asia/Shanghai" + +EXPOSE 21114/tcp +EXPOSE 21114/udp + +ENTRYPOINT ["sh", "run.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4628bff --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# rustdesk-api-server + +## based on https://github.com/kingmo888/rustdesk-api-server + +![Main Page](images/user_devices.png) +![Connection Log](images/connection_log.png) +![File Transfer Log](images/file_log.png) +![Client Downloads](images/clients.png) + +## Features + +- Supports self-registration and login on the front-end webpage. +- Supports displaying device information on the front end, divided into administrator and user versions. +- Supports custom aliases (remarks). +- Supports backend management. +- Supports colored tags. +- Supports device online statistics. +- Supports saving device passwords. +- Automatically manages tokens and keeps them alive using the heartbeat interface. +- Supports sharing devices with other users. +- Supports web control terminal (currently only supports non-SSL mode, see below for usage issues) + +## Installation + +```bash +# open to the directory you want to install the api server (change /opt to wherever you want) +cd /opt +# Clone the code locally +git clone https://github.com/bryangerlach/rustdesk-api-server.git +# Enter the directory +cd rustdesk-api-server +# setup a python virtual environment called rdgen +python -m venv rustdesk-api +# activate the python virtual environment +source rustdesk-api/bin/activate +# Install dependencies +pip install -r requirements.txt +python manage.py migrate +# After ensuring dependencies are installed correctly, execute: +python manage.py runserver 0.0.0.0:21114 +``` + +## ## To autostart the server on boot, you can set up a systemd service called rustdeskapi.service + +``` +[Unit] +Description=Rustdesk API Server +[Service] +Type=simple +LimitNOFILE=1000000 +ExecStart=/opt/rustdesk-api-server/rustdesk-api/bin/python3 /opt/rustdesk-api-server/manage.py runserver 0.0.0.0:21114 +WorkingDirectory=/opt/rustdesk-api-server/ +User=root +Group=root +Restart=always +StandardOutput=file:/var/log/rustdesk/apiserver.log +StandardError=file:/var/log/rustdesk/apiserver.error +# Restart service after 10 seconds if node service crashes +RestartSec=10 +[Install] +WantedBy=multi-user.target +``` + +## Updating + +```bash +#this assumes you have cloned the repo into /opt/rustdesk/rustdesk-api-server and have a service named rustdeskapi set up +systemctl stop rustdeskapi +cd /opt/rustdesk/rustdesk-api-server +git pull +source rustdesk-api/bin/activate +pip install -r requirements.txt +python manage.py migrate +systemctl start rustdeskapi +``` + +**Client Downlaods**: You will need to generate your own client downloads with your server and key hard coded into the program. The easiest way to do this is using github actions https://rustdesk.com/docs/en/dev/build/all/ + +Now you can access it using `http://localhostIP:Port`. + +**Note**: When configuring on CentOS, Django4 may have problems due to the low version of sqlite3 in the system. Please modify the file in the dependency library. Path: `xxxx/Lib/site-packages/django/db/backends/sqlite3/base.py` (Find the package address according to the situation), modify the content: +```python +# from sqlite3 import dbapi2 as Database #(comment out this line) +from pysqlite3 import dbapi2 as Database # enable pysqlite3 +``` + +## Environment Variables + +| Variable Name | Reference Value | Note | +| ---- | ------- | ----------- | +| `HOST` | Default `0.0.0.0` | IP binding of the service | +| `TZ` | Default `Asia/Shanghai`, optional | Timezone | +| `SECRET_KEY` | Optional, custom a random string | Program encryption key | +| `CSRF_TRUSTED_ORIGINS` | Optional, verification off by default;
If you need to enable it, fill in your access address `http://yourdomain.com:21114`
**To disable verification, please delete this variable instead of leaving it blank** | Cross-origin trusted source | +| `ID_SERVER` | Optional, default is the same host as the API server.
Customizable like `yourdomain.com` | ID server used by the web control terminal | +| `DEBUG` | Optional, default `False` | Debug mode | +| `ALLOW_REGISTRATION` | Optional, default `True` | Whether to allow new user registration | +| Database Configuration | -- Start -- | If not using MYSQL, the following are unnecessary | +| `DATABASE_TYPE` | Optional, default `SQLITE3` | Database type (SQLITE/MYSQL) | +| `MYSQL_DBNAME` | Optional, default `-` | MYSQL database name | +| `MYSQL_HOST` | Optional, default `127.0.0.1` | MYSQL database server IP | +| `MYSQL_USER` | Optional, default `-` | MYSQL database username | +| `MYSQL_PASSWORD` | Optional, default `-` | MYSQL database password | +| `MYSQL_PORT` | Optional, default `3306` | MYSQL database port | +| Database Configuration | -- End -- | See [sqlite3 migration to mysql tutorial](/tutorial/sqlite2mysql.md) | + +## Usage Issues + +- Administrator Settings + + When there are no accounts in the database, the first registered account directly obtains super administrator privileges, + + and subsequently registered accounts are ordinary accounts. + +- Device Information + + Tested, the client will send device information to the API interface regularly in the mode of installation as a service under non-green mode, so if you want device information, you need to install the Rustdesk client and start the service. + +- Slow Connection Speed + + Use the client generator to generate a client that has the connection delay removed + +- Web Control Terminal Configuration + + - Set the ID_SERVER environment variable or modify the ID_SERVER configuration item in the rustdesk_server_api/settings.py file and fill in the IP or domain name of the ID server/relay server. + +- Web Control Terminal Keeps Spinning + + - Check if the ID server filling is correct. + + - The web control terminal currently only supports non-SSL mode. If the webui is accessed via https, remove the 's', otherwise ws cannot connect and keeps spinning. For example: https://domain.com/webui, change to http://domain.com/webui + +- CSRF verification failed when logging in or logging out of backend operations. Request interrupted. + + This operation is highly likely to be a combination of docker configuration + nginx reverse proxy + SSL. Pay attention to modifying CSRF_TRUSTED_ORIGINS. If it is SSL, it starts with https, otherwise it is http. + +## Other Related Tools + +- [rdgen](https://github.com/bryangerlach/rdgen) + +- [infinite remote](https://github.com/infiniteremote/installer) + +- [CMD script for modifying client ID](https://github.com/abdullah-erturk/RustDesk-ID-Changer) + +- [rustdesk](https://github.com/rustdesk/rustdesk) + +- [rustdesk-server](https://github.com/rustdesk/rustdesk-server) diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..cc88647 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,210 @@ +# rustdesk-api-server + +## If the project has helped you, giving a star isn't too much, right? + +## Please use the latest version 1.2.3 of the client. + +[点击这里查看中文说明。](https://github.com/kingmo888/rustdesk-api-server/blob/master/README.md) + +

+ A Rustdesk API interface implemented in Python, with WebUI management support +
+ + + +
+ + +

+ +![Main Page](images/front_main.png) + +## Features + +- Supports self-registration and login on the front-end webpage. + - Registration and login pages: + ![Front Registration](images/front_reg.png) + ![Front Login](images/front_login.png) + +- Supports displaying device information on the front end, divided into administrator and user versions. +- Supports custom aliases (remarks). +- Supports backend management. +- Supports colored tags. +![Rust Books](images/rust_books.png) + +- Supports device online statistics. +- Supports saving device passwords. +- Automatically manages tokens and keeps them alive using the heartbeat interface. +- Supports sharing devices with other users. +![Rust Share](images/share.png) +- Supports web control terminal (currently only supports non-SSL mode, see below for usage issues) +![Rust Share](images/webui.png) + +Admin Home Page: +![Admin Main](images/admin_main.png) + +## Installation + +### Method 1: Out-of-the-box + +Only supports Windows, please go to the release to download, no need to install environment, just run `启动.bat` directly. Screenshots: + +![Windows Run Directly Version](/images/windows_run.png) + + +### Method 2: Running the Code + +```bash +# Clone the code locally +git clone https://github.com/kingmo888/rustdesk-api-server.git +# Enter the directory +cd rustdesk-api-server +# Install dependencies +pip install -r requirements.txt +# After ensuring dependencies are installed correctly, execute: +# Please modify the port number yourself, it is recommended to keep 21114 as the default port for Rustdesk API +python manage.py runserver 0.0.0.0:21114 +``` + +Now you can access it using `http://localhostIP:Port`. + +**Note**: When configuring on CentOS, Django4 may have problems due to the low version of sqlite3 in the system. Please modify the file in the dependency library. Path: `xxxx/Lib/site-packages/django/db/backends/sqlite3/base.py` (Find the package address according to the situation), modify the content: +```python +# from sqlite3 import dbapi2 as Database #(comment out this line) +from pysqlite3 import dbapi2 as Database # enable pysqlite3 +``` + +### Method 3: Docker Run + +#### Docker Method 1: Build Yourself +```bash +git clone https://github.com/kingmo888/rustdesk-api-server.git +cd rustdesk-api-server +docker compose --compatibility up --build -d +``` +Thanks to the enthusiastic netizen @ferocknew for providing. + +#### Docker Method 2: Pre-built Run + +docker run command: + +```bash +docker run -d \ + --name rustdesk-api-server \ + -p 21114:21114 \ + -e CSRF_TRUSTED_ORIGINS=http://yourdomain.com:21114 \ #Cross-origin trusted source, optional + -e ID_SERVER=yourdomain.com \ #ID server used by the web control terminal + -v /yourpath/db:/rustdesk-api-server/db \ #Modify /yourpath/db to your host database mount directory + -v /etc/timezone:/etc/timezone:ro \ + -v /etc/localtime:/etc/localtime:ro \ + --network bridge \ + --restart unless-stopped \ + ghcr.io/kingmo888/rustdesk-api-server:latest +``` + +docker-compose method: + +```yaml +version: "3.8" +services: + rustdesk-api-server: + container_name: rustdesk-api-server + image: ghcr.io/kingmo888/rustdesk-api-server:latest + environment: + - CSRF_TRUSTED_ORIGINS=http://yourdomain.com:21114 #Cross-origin trusted source, optional + - ID_SERVER=yourdomain.com #ID server used by the web control terminal + volumes: + - /yourpath/db:/rustdesk-api-server/db #Modify /yourpath/db to your host database mount directory + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + network_mode: bridge + ports: + - "21114:21114" + restart: unless-stopped +``` + +## Environment Variables + +| Variable Name | Reference Value | Note | +| ---- | ------- | ----------- | +| `HOST` | Default `0.0.0.0` | IP binding of the service | +| `TZ` | Default `Asia/Shanghai`, optional | Timezone | +| `SECRET_KEY` | Optional, custom a random string | Program encryption key | +| `CSRF_TRUSTED_ORIGINS` | Optional, verification off by default;
If you need to enable it, fill in your access address `http://yourdomain.com:21114`
**To disable verification, please delete this variable instead of leaving it blank** | Cross-origin trusted source | +| `ID_SERVER` | Optional, default is the same host as the API server.
Customizable like `yourdomain.com` | ID server used by the web control terminal | +| `DEBUG` | Optional, default `False` | Debug mode | +| `ALLOW_REGISTRATION` | Optional, default `True` | Whether to allow new user registration | +| Database Configuration | -- Start -- | If not using MYSQL, the following are unnecessary | +| `DATABASE_TYPE` | Optional, default `SQLITE3` | Database type (SQLITE/MYSQL) | +| `MYSQL_DBNAME` | Optional, default `-` | MYSQL database name | +| `MYSQL_HOST` | Optional, default `127.0.0.1` | MYSQL database server IP | +| `MYSQL_USER` | Optional, default `-` | MYSQL database username | +| `MYSQL_PASSWORD` | Optional, default `-` | MYSQL database password | +| `MYSQL_PORT` | Optional, default `3306` | MYSQL database port | +| Database Configuration | -- End -- | See [sqlite3 migration to mysql tutorial](/tutorial/sqlite2mysql.md) | + +## Usage Issues + +- Administrator Settings + + When there are no accounts in the database, the first registered account directly obtains super administrator privileges, + + and subsequently registered accounts are ordinary accounts. + +- Device Information + + Tested, the client will send device information to the API interface regularly in the mode of installation as a service under non-green mode, so if you want device information, you need to install the Rustdesk client and start the service. + +- Slow Connection Speed + + The new version Key mode connection speed is slow. You can start the service on the server without the -k parameter. At this time, the client cannot configure the key either. + +- Web Control Terminal Configuration + + - Set the ID_SERVER environment variable or modify the ID_SERVER configuration item in the rustdesk_server_api/settings.py file and fill in the IP or domain name of the ID server/relay server. + +- Web Control Terminal Keeps Spinning + + - Check if the ID server filling is correct. + + - The web control terminal currently only supports non-SSL mode. If the webui is accessed via https, remove the 's', otherwise ws cannot connect and keeps spinning. For example: https://domain.com/webui, change to http://domain.com/webui + +- CSRF verification failed when logging in or logging out of backend operations. Request interrupted. + + This operation is highly likely to be a combination of docker configuration + nginx reverse proxy + SSL. Pay attention to modifying CSRF_TRUSTED_ORIGINS. If it is SSL, it starts with https, otherwise it is http. + +## Development Plans + +- [x] Share devices with other registered users (v1.3+) + + > Explanation: Similar to sharing URLs of network disks, the URL can be activated to obtain devices under a certain group or certain label. + > Note: In fact, there is not much that can be done with the web API as middleware. More functions still need to be implemented by modifying the client, which is not very worthwhile. + +- [x] Integration of Web client form (v1.4+) + + > Integrating the great god's web client, already integrated. [Source](https://www.52pojie.cn/thread-1708319-1-1.html) + +- [x] Filter expired (offline) devices to distinguish between online and offline devices (1.4.7) + + > By configuration, clean or filter devices that have expired for more than a specified time. + +- [x] Split the first screen into user list page and administrator list page and add pagination (1.4.6). + +- [x] Support exporting information to xlsx files (1.4.6). + + > Allows administrators to export all device information on the [All Devices] page. + +- [x] Set whether to allow new user registration through configuration items (1.4.7). + +- [x] Support mysql and sqlite3 migration to mysql (1.4.8). + +## Other Related Tools + +- [CMD script for modifying client ID](https://github.com/abdullah-erturk/RustDesk-ID-Changer) + +- [rustdesk](https://github.com/rustdesk/rustdesk) + +- [rustdesk-server](https://github.com/rustdesk/rustdesk-server) + +## Stargazers over time +[![Stargazers over time](https://starchart.cc/kingmo888/rustdesk-api-server.svg?variant=adaptive)](https://starchart.cc/kingmo888/rustdesk-api-server) \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..1a781ed --- /dev/null +++ b/api/admin.py @@ -0,0 +1 @@ +from .admin_user import * \ No newline at end of file diff --git a/api/admin_user.py b/api/admin_user.py new file mode 100644 index 0000000..9e0b415 --- /dev/null +++ b/api/admin_user.py @@ -0,0 +1,101 @@ +# cython:language_level=3 +from django.contrib import admin +from api import models +from django import forms +from django.contrib.auth.models import Group +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.utils.translation import gettext as _ + + +class UserCreationForm(forms.ModelForm): + """A form for creating new users. Includes all the required + fields, plus a repeated password.""" + password1 = forms.CharField(label=_('密码'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('再次输入密码'), widget=forms.PasswordInput) + + class Meta: + model = models.UserProfile + fields = ('username','is_active','is_admin') + + def clean_password2(self): + # Check that the two password entries match + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("密码校验失败,两次密码不一致。")) + return password2 + + + def save(self, commit=True): + # Save the provided password in hashed format + user = super(UserCreationForm, self).save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class UserChangeForm(forms.ModelForm): + """A form for updating users. Includes all the fields on + the user, but replaces the password field with admin's + password hash display field. + """ + password = ReadOnlyPasswordHashField(label=(_("密码Hash值")), help_text=("点击修改密码.")) + class Meta: + model = models.UserProfile + fields = ('username', 'is_active', 'is_admin') + + def clean_password(self): + # Regardless of what the user provides, return the initial value. + # This is done here, rather than on the field, because the + # field does not have access to the initial value + return self.initial["password"] + #return self.initial["password"] + + def save(self, commit=True): + # Save the provided password in hashed format + user = super(UserChangeForm, self).save(commit=False) + + if commit: + user.save() + return user + +class UserAdmin(BaseUserAdmin): + # The forms to add and change user instances + form = UserChangeForm + add_form = UserCreationForm + password = ReadOnlyPasswordHashField(label=("Password HASH value"), help_text=("Click to modify the password.")) + # The fields to be used in displaying the User model. + # These override the definitions on the base UserAdmin + # that reference specific fields on auth.User. + list_display = ('username', 'rid') + list_filter = ('is_admin', 'is_active') + fieldsets = ( + (_('基本信息'), {'fields': ('username', 'password', 'is_active', 'is_admin', 'rid', 'uuid', 'deviceInfo',)}), + + ) + readonly_fields = ( 'rid', 'uuid') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'is_active', 'is_admin', 'password1', 'password2', )} + ), + ) + + search_fields = ('username', ) + ordering = ('username',) + filter_horizontal = () + + +admin.site.register(models.UserProfile, UserAdmin) +admin.site.register(models.RustDeskToken, models.RustDeskTokenAdmin) +admin.site.register(models.RustDeskTag, models.RustDeskTagAdmin) +admin.site.register(models.RustDeskPeer, models.RustDeskPeerAdmin) +admin.site.register(models.RustDesDevice, models.RustDesDeviceAdmin) +admin.site.register(models.ShareLink, models.ShareLinkAdmin) +admin.site.register(models.ConnLog, models.ConnLogAdmin) +admin.site.register(models.FileLog, models.FileLogAdmin) +admin.site.unregister(Group) +admin.site.site_header = _('RustDesk自建Web') +admin.site.site_title = _('未定义') diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..d87006d --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/api/forms.py b/api/forms.py new file mode 100644 index 0000000..fb7a5de --- /dev/null +++ b/api/forms.py @@ -0,0 +1,102 @@ +from django import forms +from api.models import UserProfile + +class GenerateForm(forms.Form): + #Platform + platform = forms.ChoiceField(choices=[('windows','Windows'),('linux','Linux (currently unavailable)'),('android','Android (testing now available)')], initial='windows') + version = forms.ChoiceField(choices=[('master','beta'),('1.3.2','1.3.2'),('1.3.1','1.3.1'),('1.3.0','1.3.0')], initial='1.3.2') + delayFix = forms.BooleanField(initial=True, required=False) + + #General + exename = forms.CharField(label="Name for EXE file", required=True) + appname = forms.CharField(label="Custom App Name", required=False) + direction = forms.ChoiceField(widget=forms.RadioSelect, choices=[ + ('incoming', 'Incoming Only'), + ('outgoing', 'Outgoing Only'), + ('both', 'Bidirectional') + ], initial='both') + installation = forms.ChoiceField(label="Disable Installation", choices=[ + ('installationY', 'No, enable installation'), + ('installationN', 'Yes, DISABLE installation') + ], initial='installationY') + settings = forms.ChoiceField(label="Disable Settings", choices=[ + ('settingsY', 'No, enable settings'), + ('settingsN', 'Yes, DISABLE settings') + ], initial='settingsY') + + #Custom Server + serverIP = forms.CharField(label="Host", required=False) + apiServer = forms.CharField(label="API Server", required=False) + key = forms.CharField(label="Key", required=False) + urlLink = forms.CharField(label="Custom URL for links", required=False) + + #Visual + iconfile = forms.FileField(label="Custom App Icon (in .png format)", required=False, widget=forms.FileInput(attrs={'accept': 'image/png'})) + logofile = forms.FileField(label="Custom App Logo (in .png format)", required=False, widget=forms.FileInput(attrs={'accept': 'image/png'})) + theme = forms.ChoiceField(choices=[ + ('light', 'Light'), + ('dark', 'Dark'), + ('system', 'Follow System') + ], initial='system') + themeDorO = forms.ChoiceField(choices=[('default', 'Default'),('override', 'Override')], initial='default') + + #Security + passApproveMode = forms.ChoiceField(choices=[('password','Accept sessions via password'),('click','Accept sessions via click'),('password-click','Accepts sessions via both')],initial='password-click') + permanentPassword = forms.CharField(widget=forms.PasswordInput(), required=False) + runasadmin = forms.ChoiceField(choices=[('false','No'),('true','Yes')], initial='false') + denyLan = forms.BooleanField(initial=False, required=False) + enableDirectIP = forms.BooleanField(initial=False, required=False) + #ipWhitelist = forms.BooleanField(initial=False, required=False) + autoClose = forms.BooleanField(initial=False, required=False) + + #Permissions + permissionsDorO = forms.ChoiceField(choices=[('default', 'Default'),('override', 'Override')], initial='default') + permissionsType = forms.ChoiceField(choices=[('custom', 'Custom'),('full', 'Full Access'),('view','Screen share')], initial='custom') + enableKeyboard = forms.BooleanField(initial=True, required=False) + enableClipboard = forms.BooleanField(initial=True, required=False) + enableFileTransfer = forms.BooleanField(initial=True, required=False) + enableAudio = forms.BooleanField(initial=True, required=False) + enableTCP = forms.BooleanField(initial=True, required=False) + enableRemoteRestart = forms.BooleanField(initial=True, required=False) + enableRecording = forms.BooleanField(initial=True, required=False) + enableBlockingInput = forms.BooleanField(initial=True, required=False) + enableRemoteModi = forms.BooleanField(initial=False, required=False) + + #Other + removeWallpaper = forms.BooleanField(initial=True, required=False) + + defaultManual = forms.CharField(widget=forms.Textarea, required=False) + overrideManual = forms.CharField(widget=forms.Textarea, required=False) + +class AddPeerForm(forms.Form): + clientID = forms.CharField(label="Client Rustdesk ID", required=True) + alias = forms.CharField(label="Client alias", required=True) + tags = forms.CharField(label="Tags", required=False) + username = forms.CharField(label="Username", required=False) + hostname = forms.CharField(label="OS", required=False) + platform = forms.CharField(label="Platform", required=False) + ip = forms.CharField(label="IP", required=False) + +class AssignPeerForm(forms.Form): + uid = forms.ModelChoiceField( + queryset=UserProfile.objects.all(), + to_field_name='id', + empty_label='Select a User', + required=True + ) + clientID = forms.CharField(label="Client Rustdesk ID", required=True) + alias = forms.CharField(label="Client alias", required=True) + tags = forms.CharField(label="Tags", required=False) + username = forms.CharField(label="Username", required=False) + hostname = forms.CharField(label="Hostname", required=False) + platform = forms.CharField(label="Platform", required=False) + ip = forms.CharField(label="IP", required=False) + +class EditPeerForm(forms.Form): + clientID = forms.CharField(label="Client Rustdesk ID", required=True) + alias = forms.CharField(label="Client alias", required=True) + tags = forms.CharField(label="Tags", required=False) + username = forms.CharField(label="Username", required=False) + hostname = forms.CharField(label="OS", required=False) + platform = forms.CharField(label="Platform", required=False) + ip = forms.CharField(label="IP", required=False) \ No newline at end of file diff --git a/api/front_locale.py b/api/front_locale.py new file mode 100644 index 0000000..42edd0a --- /dev/null +++ b/api/front_locale.py @@ -0,0 +1,78 @@ +from django.utils.translation import gettext as _ + + +_('管理后台') +_('ID列表') +_('分享机器') +_('这么简易的东西,忘记密码这功能就没必要了吧。') +_('立即注册') +_('创建时间') +_('注册成功,请前往登录页登录。') +_('注册日期') +_('2、所分享的机器,被分享人享有相同的权限,如果机器设置了保存密码,被分享人也可以直接连接。') +_('导出xlsx') +_('生成分享链接') +_('请输入8~20位密码。可以包含字母、数字和特殊字符。') +_('尾页') +_('请确认密码') +_('注册') +_('内存') +_('首页') +_('网页控制') +_('注册时间') +_('链接地址') +_('请输入密码') +_('系统用户名') +_('状态') +_('已有账号?立即登录') +_('密码') +_('别名') +_('上一页') +_('更新时间') +_('综合屏') +_('平台') +_('全部用户') +_('注册页') +_('分享机器给其他用户') +_('所有设备') +_('连接密码') +_('设备统计') +_('所属用户') +_('分享') +_('请输入用户名') +_('1、链接有效期为15分钟,切勿随意分享给他人。') +_('CPU') +_('客户端ID') +_('下一页') +_('登录') +_('退出') +_('请将要分享的机器调整到右侧') +_('成功!如需分享,请复制以下链接给其他人:
') +_('忘记密码?') +_('计算机名') +_('两次输入密码不一致!') +_('页码') +_('版本') +_('用户名') +_('3、为保障安全,链接有效期为15分钟、链接仅有效1次。链接一旦被(非分享人的登录用户)访问,分享生效,后续访问链接失效。') +_('系统') +_('我的机器') +_('信息') +_('远程ID') +_('远程别名') +_('用户ID') +_('用户别名') +_('用户IP') +_('文件大小') +_('发送/接受') +_('记录于') +_('连接开始时间') +_('连接结束时间') +_('时长') +_('连接日志') +_('文件传输日志') +_('页码 #') +_('下一页 #') +_('上一页 #') +_('第一页') +_('上页') \ No newline at end of file diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..3778ccd --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,243 @@ +# Generated by Django 4.2.7 on 2023-12-14 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="RustDesDevice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rid", + models.CharField(blank=True, max_length=60, verbose_name="Client ID"), + ), + ("cpu", models.CharField(max_length=20, verbose_name="CPU")), + ("hostname", models.CharField(max_length=20, verbose_name="CPU name")), + ("memory", models.CharField(max_length=20, verbose_name="Memory")), + ("os", models.CharField(max_length=20, verbose_name="operating system")), + ("uuid", models.CharField(max_length=60, verbose_name="uuid")), + ( + "username", + models.CharField(blank=True, max_length=60, verbose_name="System username"), + ), + ("version", models.CharField(max_length=20, verbose_name="Client version")), + ( + "create_time", + models.DateTimeField(auto_now_add=True, verbose_name="Equipment registration time"), + ), + ( + "update_time", + models.DateTimeField(auto_now=True, verbose_name="Equipment update time"), + ), + ], + options={ + "verbose_name": "equipment", + "verbose_name_plural": "Device List", + "ordering": ("-rid",), + }, + ), + migrations.CreateModel( + name="RustDeskPeer", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uid", models.CharField(max_length=16, verbose_name="User ID")), + ("rid", models.CharField(max_length=60, verbose_name="Client ID")), + ("username", models.CharField(max_length=20, verbose_name="System username")), + ("hostname", models.CharField(max_length=30, verbose_name="Operating system name")), + ("alias", models.CharField(max_length=30, verbose_name="Alias")), + ("platform", models.CharField(max_length=30, verbose_name="platform")), + ("tags", models.CharField(max_length=30, verbose_name="Label")), + ("rhash", models.CharField(max_length=60, verbose_name="Device link password")), + ], + options={ + "verbose_name": "Peers", + "verbose_name_plural": "PEERS list", + "ordering": ("-username",), + }, + ), + migrations.CreateModel( + name="RustDeskTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uid", models.CharField(max_length=16, verbose_name="User ID")), + ("tag_name", models.CharField(max_length=60, verbose_name="Tag name")), + ( + "tag_color", + models.CharField(blank=True, max_length=60, verbose_name="Tag color"), + ), + ], + options={ + "verbose_name": "Tags", + "verbose_name_plural": "Tags list", + "ordering": ("-uid",), + }, + ), + migrations.CreateModel( + name="RustDeskToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("username", models.CharField(max_length=20, verbose_name="username")), + ("rid", models.CharField(max_length=16, verbose_name="RustDesk ID")), + ("uid", models.CharField(max_length=16, verbose_name="User ID")), + ("uuid", models.CharField(max_length=60, verbose_name="uuid")), + ( + "access_token", + models.CharField( + blank=True, max_length=60, verbose_name="access_token" + ), + ), + ( + "create_time", + models.DateTimeField(auto_now_add=True, verbose_name="Log in time"), + ), + ], + options={ + "verbose_name": "Token", + "verbose_name_plural": "Token list", + "ordering": ("-username",), + }, + ), + migrations.CreateModel( + name="ShareLink", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uid", models.CharField(max_length=16, verbose_name="User ID")), + ("shash", models.CharField(max_length=60, verbose_name="Link Key")), + ("peers", models.CharField(max_length=20, verbose_name="Machine ID list")), + ("is_used", models.BooleanField(default=False, verbose_name="use or not")), + ("is_expired", models.BooleanField(default=False, verbose_name="Whether to expire")), + ( + "create_time", + models.DateTimeField(auto_now_add=True, verbose_name="Generation time"), + ), + ], + options={ + "verbose_name": "Share link", + "verbose_name_plural": "Link list", + "ordering": ("-create_time",), + }, + ), + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField(max_length=50, unique=True, verbose_name="username"), + ), + ("rid", models.CharField(max_length=16, verbose_name="RustDesk ID")), + ("uuid", models.CharField(max_length=60, verbose_name="uuid")), + ( + "autoLogin", + models.BooleanField(default=True, verbose_name="autoLogin"), + ), + ("rtype", models.CharField(max_length=20, verbose_name="rtype")), + ("deviceInfo", models.TextField(blank=True, verbose_name="login information:")), + ("is_active", models.BooleanField(default=True, verbose_name="Activate now")), + ("is_admin", models.BooleanField(default=False, verbose_name="Whether a administrator")), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "user list", + "permissions": ( + ("view_task", "Can see available tasks"), + ("change_task_status", "Can change the status of tasks"), + ("close_task", "Can remove a task by setting its status as closed"), + ), + }, + ), + ] diff --git a/api/migrations/0002_alter_rustdesdevice_cpu_alter_rustdesdevice_hostname_and_more.py b/api/migrations/0002_alter_rustdesdevice_cpu_alter_rustdesdevice_hostname_and_more.py new file mode 100644 index 0000000..86a7ac6 --- /dev/null +++ b/api/migrations/0002_alter_rustdesdevice_cpu_alter_rustdesdevice_hostname_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.7 on 2024-02-21 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="rustdesdevice", + name="cpu", + field=models.CharField(max_length=100, verbose_name="CPU"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="hostname", + field=models.CharField(max_length=100, verbose_name="主机名"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="memory", + field=models.CharField(max_length=100, verbose_name="内存"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="os", + field=models.CharField(max_length=100, verbose_name="操作系统"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="username", + field=models.CharField(blank=True, max_length=100, verbose_name="系统用户名"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="uuid", + field=models.CharField(max_length=100, verbose_name="uuid"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="version", + field=models.CharField(max_length=100, verbose_name="客户端版本"), + ), + ] diff --git a/api/migrations/0003_alter_rustdesdevice_options_and_more.py b/api/migrations/0003_alter_rustdesdevice_options_and_more.py new file mode 100644 index 0000000..608ef94 --- /dev/null +++ b/api/migrations/0003_alter_rustdesdevice_options_and_more.py @@ -0,0 +1,238 @@ +# Generated by Django 4.2.7 on 2024-03-15 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0002_alter_rustdesdevice_cpu_alter_rustdesdevice_hostname_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="rustdesdevice", + options={ + "ordering": ("-rid",), + "verbose_name": "Device", + "verbose_name_plural": "Device List", + }, + ), + migrations.AlterModelOptions( + name="rustdeskpeer", + options={ + "ordering": ("-username",), + "verbose_name": "Peers", + "verbose_name_plural": "Peers List", + }, + ), + migrations.AlterModelOptions( + name="rustdesktag", + options={ + "ordering": ("-uid",), + "verbose_name": "Tags", + "verbose_name_plural": "Tags List", + }, + ), + migrations.AlterModelOptions( + name="rustdesktoken", + options={ + "ordering": ("-username",), + "verbose_name": "Token", + "verbose_name_plural": "Token List", + }, + ), + migrations.AlterModelOptions( + name="sharelink", + options={ + "ordering": ("-create_time",), + "verbose_name": "Share Link", + "verbose_name_plural": "Link List", + }, + ), + migrations.AlterModelOptions( + name="userprofile", + options={ + "permissions": ( + ("view_task", "Can see available tasks"), + ("change_task_status", "Can change the status of tasks"), + ("close_task", "Can remove a task by setting its status as closed"), + ), + "verbose_name": "User", + "verbose_name_plural": "User List", + }, + ), + migrations.AlterField( + model_name="rustdesdevice", + name="create_time", + field=models.DateTimeField( + auto_now_add=True, verbose_name="Device Registration Time" + ), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="hostname", + field=models.CharField(max_length=100, verbose_name="Hostname"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="memory", + field=models.CharField(max_length=100, verbose_name="Memory"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="os", + field=models.CharField(max_length=100, verbose_name="Operating System"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="rid", + field=models.CharField(blank=True, max_length=60, verbose_name="Client ID"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="username", + field=models.CharField( + blank=True, max_length=100, verbose_name="System Username" + ), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="version", + field=models.CharField(max_length=100, verbose_name="Client Version"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="alias", + field=models.CharField(max_length=30, verbose_name="Alias"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="hostname", + field=models.CharField(max_length=30, verbose_name="Operating System Name"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="platform", + field=models.CharField(max_length=30, verbose_name="Platform"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="rhash", + field=models.CharField( + max_length=60, verbose_name="Device Connection Password" + ), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="rid", + field=models.CharField(max_length=60, verbose_name="Client ID"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="tags", + field=models.CharField(max_length=30, verbose_name="Tag"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="uid", + field=models.CharField(max_length=16, verbose_name="User ID"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="username", + field=models.CharField(max_length=20, verbose_name="System Username"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="tag_color", + field=models.CharField(blank=True, max_length=60, verbose_name="Tag Color"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="tag_name", + field=models.CharField(max_length=60, verbose_name="Tag Name"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="uid", + field=models.CharField(max_length=16, verbose_name="Belongs to User ID"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="access_token", + field=models.CharField( + blank=True, max_length=60, verbose_name="Access Token" + ), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="create_time", + field=models.DateTimeField(auto_now_add=True, verbose_name="Login Time"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="uid", + field=models.CharField(max_length=16, verbose_name="User ID"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="username", + field=models.CharField(max_length=20, verbose_name="Username"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="uuid", + field=models.CharField(max_length=60, verbose_name="UUID"), + ), + migrations.AlterField( + model_name="sharelink", + name="create_time", + field=models.DateTimeField(auto_now_add=True, verbose_name="Creation Time"), + ), + migrations.AlterField( + model_name="sharelink", + name="is_expired", + field=models.BooleanField(default=False, verbose_name="Is Expired"), + ), + migrations.AlterField( + model_name="sharelink", + name="is_used", + field=models.BooleanField(default=False, verbose_name="Is Used"), + ), + migrations.AlterField( + model_name="sharelink", + name="peers", + field=models.CharField(max_length=20, verbose_name="Machine ID List"), + ), + migrations.AlterField( + model_name="sharelink", + name="shash", + field=models.CharField(max_length=60, verbose_name="Link Key"), + ), + migrations.AlterField( + model_name="sharelink", + name="uid", + field=models.CharField(max_length=16, verbose_name="User ID"), + ), + migrations.AlterField( + model_name="userprofile", + name="deviceInfo", + field=models.TextField(blank=True, verbose_name="Login Information:"), + ), + migrations.AlterField( + model_name="userprofile", + name="is_active", + field=models.BooleanField(default=True, verbose_name="Is Activated"), + ), + migrations.AlterField( + model_name="userprofile", + name="is_admin", + field=models.BooleanField(default=False, verbose_name="Is Admin"), + ), + migrations.AlterField( + model_name="userprofile", + name="username", + field=models.CharField(max_length=50, unique=True, verbose_name="Username"), + ), + ] diff --git a/api/migrations/0004_alter_rustdesdevice_options_and_more.py b/api/migrations/0004_alter_rustdesdevice_options_and_more.py new file mode 100644 index 0000000..f59f783 --- /dev/null +++ b/api/migrations/0004_alter_rustdesdevice_options_and_more.py @@ -0,0 +1,232 @@ +# Generated by Django 4.2.7 on 2024-03-15 23:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0003_alter_rustdesdevice_options_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="rustdesdevice", + options={ + "ordering": ("-rid",), + "verbose_name": "设备", + "verbose_name_plural": "设备列表", + }, + ), + migrations.AlterModelOptions( + name="rustdeskpeer", + options={ + "ordering": ("-username",), + "verbose_name": "Peers", + "verbose_name_plural": "Peers列表", + }, + ), + migrations.AlterModelOptions( + name="rustdesktag", + options={ + "ordering": ("-uid",), + "verbose_name": "Tags", + "verbose_name_plural": "Tags列表", + }, + ), + migrations.AlterModelOptions( + name="rustdesktoken", + options={ + "ordering": ("-username",), + "verbose_name": "Token", + "verbose_name_plural": "Token列表", + }, + ), + migrations.AlterModelOptions( + name="sharelink", + options={ + "ordering": ("-create_time",), + "verbose_name": "分享链接", + "verbose_name_plural": "链接列表", + }, + ), + migrations.AlterModelOptions( + name="userprofile", + options={ + "permissions": ( + ("view_task", "Can see available tasks"), + ("change_task_status", "Can change the status of tasks"), + ("close_task", "Can remove a task by setting its status as closed"), + ), + "verbose_name": "用户", + "verbose_name_plural": "用户列表", + }, + ), + migrations.AlterField( + model_name="rustdesdevice", + name="create_time", + field=models.DateTimeField(auto_now_add=True, verbose_name="设备注册时间"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="hostname", + field=models.CharField(max_length=100, verbose_name="主机名"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="memory", + field=models.CharField(max_length=100, verbose_name="内存"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="os", + field=models.CharField(max_length=100, verbose_name="操作系统"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="rid", + field=models.CharField(blank=True, max_length=60, verbose_name="客户端ID"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="username", + field=models.CharField(blank=True, max_length=100, verbose_name="系统用户名"), + ), + migrations.AlterField( + model_name="rustdesdevice", + name="version", + field=models.CharField(max_length=100, verbose_name="客户端版本"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="alias", + field=models.CharField(max_length=30, verbose_name="别名"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="hostname", + field=models.CharField(max_length=30, verbose_name="操作系统名"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="platform", + field=models.CharField(max_length=30, verbose_name="平台"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="rhash", + field=models.CharField(max_length=60, verbose_name="设备链接密码"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="rid", + field=models.CharField(max_length=60, verbose_name="客户端ID"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="tags", + field=models.CharField(max_length=30, verbose_name="标签"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="uid", + field=models.CharField(max_length=16, verbose_name="用户ID"), + ), + migrations.AlterField( + model_name="rustdeskpeer", + name="username", + field=models.CharField(max_length=20, verbose_name="系统用户名"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="tag_color", + field=models.CharField(blank=True, max_length=60, verbose_name="标签颜色"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="tag_name", + field=models.CharField(max_length=60, verbose_name="标签名称"), + ), + migrations.AlterField( + model_name="rustdesktag", + name="uid", + field=models.CharField(max_length=16, verbose_name="所属用户ID"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="access_token", + field=models.CharField( + blank=True, max_length=60, verbose_name="access_token" + ), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="create_time", + field=models.DateTimeField(auto_now_add=True, verbose_name="登录时间"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="uid", + field=models.CharField(max_length=16, verbose_name="用户ID"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="username", + field=models.CharField(max_length=20, verbose_name="用户名"), + ), + migrations.AlterField( + model_name="rustdesktoken", + name="uuid", + field=models.CharField(max_length=60, verbose_name="uuid"), + ), + migrations.AlterField( + model_name="sharelink", + name="create_time", + field=models.DateTimeField(auto_now_add=True, verbose_name="生成时间"), + ), + migrations.AlterField( + model_name="sharelink", + name="is_expired", + field=models.BooleanField(default=False, verbose_name="是否过期"), + ), + migrations.AlterField( + model_name="sharelink", + name="is_used", + field=models.BooleanField(default=False, verbose_name="是否使用"), + ), + migrations.AlterField( + model_name="sharelink", + name="peers", + field=models.CharField(max_length=20, verbose_name="机器ID列表"), + ), + migrations.AlterField( + model_name="sharelink", + name="shash", + field=models.CharField(max_length=60, verbose_name="链接Key"), + ), + migrations.AlterField( + model_name="sharelink", + name="uid", + field=models.CharField(max_length=16, verbose_name="用户ID"), + ), + migrations.AlterField( + model_name="userprofile", + name="deviceInfo", + field=models.TextField(blank=True, verbose_name="登录信息:"), + ), + migrations.AlterField( + model_name="userprofile", + name="is_active", + field=models.BooleanField(default=True, verbose_name="是否激活"), + ), + migrations.AlterField( + model_name="userprofile", + name="is_admin", + field=models.BooleanField(default=False, verbose_name="是否管理员"), + ), + migrations.AlterField( + model_name="userprofile", + name="username", + field=models.CharField(max_length=50, unique=True, verbose_name="用户名"), + ), + ] diff --git a/api/migrations/0005_connlog_filelog_githubrun_and_more.py b/api/migrations/0005_connlog_filelog_githubrun_and_more.py new file mode 100644 index 0000000..e16e138 --- /dev/null +++ b/api/migrations/0005_connlog_filelog_githubrun_and_more.py @@ -0,0 +1,253 @@ +# Generated by Django 5.0.3 on 2024-10-28 10:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_alter_rustdesdevice_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ConnLog', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=20, null=True, verbose_name='Action')), + ('conn_id', models.CharField(max_length=10, null=True, verbose_name='Connection ID')), + ('from_ip', models.CharField(max_length=30, null=True, verbose_name='From IP')), + ('from_id', models.CharField(max_length=20, null=True, verbose_name='From ID')), + ('rid', models.CharField(max_length=20, null=True, verbose_name='To ID')), + ('conn_start', models.DateTimeField(null=True, verbose_name='Connected')), + ('conn_end', models.DateTimeField(null=True, verbose_name='Disconnected')), + ('session_id', models.CharField(max_length=60, null=True, verbose_name='Session ID')), + ('uuid', models.CharField(max_length=60, null=True, verbose_name='UUID')), + ], + ), + migrations.CreateModel( + name='FileLog', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.CharField(max_length=500, verbose_name='Path')), + ('remote_id', models.CharField(default='0', max_length=20, verbose_name='Remote ID')), + ('user_id', models.CharField(default='0', max_length=20, verbose_name='User ID')), + ('user_ip', models.CharField(default='0', max_length=20, verbose_name='User IP')), + ('filesize', models.CharField(default='', max_length=500, verbose_name='Filesize')), + ('direction', models.IntegerField(default=0, verbose_name='Direction')), + ('logged_at', models.DateTimeField(null=True, verbose_name='Logged At')), + ], + ), + migrations.CreateModel( + name='GithubRun', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(max_length=100, verbose_name='uuid')), + ('status', models.CharField(max_length=100, verbose_name='status')), + ], + ), + migrations.AlterModelOptions( + name='rustdesdevice', + options={'ordering': ('-rid',), 'verbose_name': 'Device', 'verbose_name_plural': 'Device List'}, + ), + migrations.AlterModelOptions( + name='rustdeskpeer', + options={'ordering': ('-username',), 'verbose_name': 'Peers', 'verbose_name_plural': 'Peers List'}, + ), + migrations.AlterModelOptions( + name='rustdesktag', + options={'ordering': ('-uid',), 'verbose_name': 'Tags', 'verbose_name_plural': 'Tags List'}, + ), + migrations.AlterModelOptions( + name='rustdesktoken', + options={'ordering': ('-username',), 'verbose_name': 'Token', 'verbose_name_plural': 'Token List'}, + ), + migrations.AlterModelOptions( + name='sharelink', + options={'ordering': ('-create_time',), 'verbose_name': 'Share Link', 'verbose_name_plural': 'Link List'}, + ), + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': (('view_task', 'Can see available tasks'), ('change_task_status', 'Can change the status of tasks'), ('close_task', 'Can remove a task by setting its status as closed')), 'verbose_name': 'User', 'verbose_name_plural': 'User List'}, + ), + migrations.AddField( + model_name='rustdesdevice', + name='ip', + field=models.CharField(default='', max_length=16, verbose_name='IP Address'), + ), + migrations.AddField( + model_name='rustdeskpeer', + name='ip', + field=models.CharField(blank=True, default='', max_length=16, verbose_name='IP Address'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='create_time', + field=models.DateTimeField(auto_now_add=True, verbose_name='Device Registration Time'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='hostname', + field=models.CharField(max_length=100, verbose_name='Hostname'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='memory', + field=models.CharField(max_length=100, verbose_name='Memory'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='os', + field=models.CharField(max_length=100, verbose_name='Operating System'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='rid', + field=models.CharField(blank=True, max_length=60, verbose_name='Client ID'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='update_time', + field=models.DateTimeField(auto_now=True, verbose_name='设备更新时间'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='username', + field=models.CharField(blank=True, max_length=100, verbose_name='System Username'), + ), + migrations.AlterField( + model_name='rustdesdevice', + name='version', + field=models.CharField(max_length=100, verbose_name='Client Version'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='alias', + field=models.CharField(max_length=30, verbose_name='Alias'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='hostname', + field=models.CharField(blank=True, max_length=30, verbose_name='Operating System Name'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='platform', + field=models.CharField(blank=True, max_length=30, verbose_name='Platform'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='rhash', + field=models.CharField(blank=True, max_length=60, verbose_name='Device Connection Password'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='rid', + field=models.CharField(max_length=60, verbose_name='Client ID'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='tags', + field=models.CharField(blank=True, max_length=30, verbose_name='Tag'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='uid', + field=models.CharField(max_length=16, verbose_name='User ID'), + ), + migrations.AlterField( + model_name='rustdeskpeer', + name='username', + field=models.CharField(blank=True, max_length=20, verbose_name='System Username'), + ), + migrations.AlterField( + model_name='rustdesktag', + name='tag_color', + field=models.CharField(blank=True, max_length=60, verbose_name='Tag Color'), + ), + migrations.AlterField( + model_name='rustdesktag', + name='tag_name', + field=models.CharField(max_length=60, verbose_name='Tag Name'), + ), + migrations.AlterField( + model_name='rustdesktag', + name='uid', + field=models.CharField(max_length=16, verbose_name='Belongs to User ID'), + ), + migrations.AlterField( + model_name='rustdesktoken', + name='access_token', + field=models.CharField(blank=True, max_length=60, verbose_name='Access Token'), + ), + migrations.AlterField( + model_name='rustdesktoken', + name='create_time', + field=models.DateTimeField(auto_now_add=True, verbose_name='Login Time'), + ), + migrations.AlterField( + model_name='rustdesktoken', + name='uid', + field=models.CharField(max_length=16, verbose_name='User ID'), + ), + migrations.AlterField( + model_name='rustdesktoken', + name='username', + field=models.CharField(max_length=20, verbose_name='Username'), + ), + migrations.AlterField( + model_name='rustdesktoken', + name='uuid', + field=models.CharField(max_length=60, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='sharelink', + name='create_time', + field=models.DateTimeField(auto_now_add=True, verbose_name='Generation Time'), + ), + migrations.AlterField( + model_name='sharelink', + name='is_expired', + field=models.BooleanField(default=False, verbose_name='Is Expired'), + ), + migrations.AlterField( + model_name='sharelink', + name='is_used', + field=models.BooleanField(default=False, verbose_name='Is Used'), + ), + migrations.AlterField( + model_name='sharelink', + name='peers', + field=models.CharField(max_length=20, verbose_name='Machine ID List'), + ), + migrations.AlterField( + model_name='sharelink', + name='shash', + field=models.CharField(max_length=60, verbose_name='Link Key'), + ), + migrations.AlterField( + model_name='sharelink', + name='uid', + field=models.CharField(max_length=16, verbose_name='User ID'), + ), + migrations.AlterField( + model_name='userprofile', + name='deviceInfo', + field=models.TextField(blank=True, verbose_name='Login Information:'), + ), + migrations.AlterField( + model_name='userprofile', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Is Active'), + ), + migrations.AlterField( + model_name='userprofile', + name='is_admin', + field=models.BooleanField(default=False, verbose_name='Is Administrator'), + ), + migrations.AlterField( + model_name='userprofile', + name='username', + field=models.CharField(max_length=50, unique=True, verbose_name='Username'), + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..4e2863d --- /dev/null +++ b/api/models.py @@ -0,0 +1,2 @@ +from .models_work import * +from .models_user import * diff --git a/api/models_user.py b/api/models_user.py new file mode 100644 index 0000000..7f53b8b --- /dev/null +++ b/api/models_user.py @@ -0,0 +1,89 @@ +# cython:language_level=3 +from django.db import models +from django.contrib.auth.models import ( + BaseUserManager,AbstractBaseUser,PermissionsMixin +) +from .models_work import * +from django.utils.translation import gettext as _ + +class MyUserManager(BaseUserManager): + def create_user(self, username, password=None): + if not username: + raise ValueError('Users must have an username') + + user = self.model(username=username, + ) + + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, password): + user = self.create_user(username, + password=password, + + ) + user.is_admin = True + user.save(using=self._db) + return user + + +class UserProfile(AbstractBaseUser, PermissionsMixin): + username = models.CharField(_('用户名'), + unique=True, + max_length=50) + + rid = models.CharField(verbose_name='RustDesk ID', max_length=16) + uuid = models.CharField(verbose_name='uuid', max_length=60) + autoLogin = models.BooleanField(verbose_name='autoLogin', default=True) + rtype = models.CharField(verbose_name='rtype', max_length=20) + deviceInfo = models.TextField(verbose_name=_('登录信息:'), blank=True) + + is_active = models.BooleanField(verbose_name=_('是否激活'), default=True) + is_admin = models.BooleanField(verbose_name=_('是否管理员'), default=False) + + objects = MyUserManager() + + USERNAME_FIELD = 'username' # Fields used for household names + REQUIRED_FIELDS = ['password'] #The field that must be filled in + + + def get_full_name(self): + # The user is identified by their email address + return self.username + + def get_short_name(self): + # The user is identified by their email address + return self.username + + def __str__(self): # __unicode__ on Python 2 + return self.username + + def has_perm(self, perm, obj=None): #Is there any specified permission + "Does the user have a specific permission?" + # Simplest possible answer: Yes, always + return True + + def has_module_perms(self, app_label): + "Does the user have permissions to view the app `app_label`?" + # Simplest possible answer: Yes, always + return True + + + + @property + def is_staff(self): + "Is the user a member of staff?" + # Simplest possible answer: All admins are staff + return self.is_admin + + class Meta: + + verbose_name = _("用户") + verbose_name_plural = _("用户列表") + permissions = ( + ("view_task", "Can see available tasks"), + ("change_task_status", "Can change the status of tasks"), + ("close_task", "Can remove a task by setting its status as closed"), + ) + diff --git a/api/models_work.py b/api/models_work.py new file mode 100644 index 0000000..8599a85 --- /dev/null +++ b/api/models_work.py @@ -0,0 +1,151 @@ +# cython:language_level=3 +from django.db import models +from django.contrib import admin +from django.utils.translation import gettext as _ + +class RustDeskToken(models.Model): + ''' Token + ''' + username = models.CharField(verbose_name=_('用户名'), max_length=20) + rid = models.CharField(verbose_name=_('RustDesk ID'), max_length=16) + uid = models.CharField(verbose_name=_('用户ID'), max_length=16) + uuid = models.CharField(verbose_name=_('uuid'), max_length=60) + access_token = models.CharField(verbose_name=_('access_token'), max_length=60, blank=True) + create_time = models.DateTimeField(verbose_name=_('登录时间'), auto_now_add=True) + #expire_time = models.DateTimeField(verbose_name='过期时间') + class Meta: + ordering = ('-username',) + verbose_name = "Token" + verbose_name_plural = _("Token列表") + +class ConnLog(models.Model): + id = models.IntegerField(verbose_name=_('ID'),primary_key=True) + action = models.CharField(verbose_name=_('Action'), max_length=20, null=True) + conn_id = models.CharField(verbose_name=_('Connection ID'), max_length=10, null=True) + from_ip = models.CharField(verbose_name=_('From IP'), max_length=30, null=True) + from_id = models.CharField(verbose_name=_('From ID'), max_length=20, null=True) + rid = models.CharField(verbose_name=_('To ID'), max_length=20, null=True) + conn_start = models.DateTimeField(verbose_name=_('Connected'), null=True) + conn_end = models.DateTimeField(verbose_name=_('Disconnected'), null=True) + session_id = models.CharField(verbose_name=_('Session ID'), max_length=60, null=True) + uuid = models.CharField(verbose_name=_('uuid'), max_length=60, null=True) + +class ConnLogAdmin(admin.ModelAdmin): + list_display = ('id', 'action', 'conn_id', 'from_ip', 'from_id', 'rid', 'conn_start', 'conn_end', 'session_id', 'uuid') + search_fields = ('from_ip', 'rid') + list_filter = ('id', 'from_ip', 'from_id', 'rid', 'conn_start', 'conn_end') + +class FileLog(models.Model): + id = models.IntegerField(verbose_name=_('ID'),primary_key=True) + file = models.CharField(verbose_name=_('Path'), max_length=500) + remote_id = models.CharField(verbose_name=_('Remote ID'), max_length=20, default='0') + user_id = models.CharField(verbose_name=_('User ID'), max_length=20, default='0') + user_ip = models.CharField(verbose_name=_('User IP'), max_length=20, default='0') + filesize = models.CharField(verbose_name=_('Filesize'), max_length=500, default='') + direction = models.IntegerField(verbose_name=_('Direction'), default=0) + logged_at = models.DateTimeField(verbose_name=_('Logged At'), null=True) + +class FileLogAdmin(admin.ModelAdmin): + list_display = ('id', 'file', 'remote_id', 'user_id', 'user_ip', 'filesize', 'direction', 'logged_at') + search_fields = ('file', 'remote_id', 'user_id', 'user_ip') + list_filter = ('id', 'file', 'remote_id', 'user_id', 'user_ip', 'filesize', 'direction', 'logged_at') + +class RustDeskTokenAdmin(admin.ModelAdmin): + list_display = ('username', 'uid') + search_fields = ('username', 'uid') + list_filter = ('create_time', ) #filter + + +class RustDeskTag(models.Model): + ''' Tags + ''' + uid = models.CharField(verbose_name=_('所属用户ID'), max_length=16) + tag_name = models.CharField(verbose_name=_('标签名称'), max_length=60) + tag_color = models.CharField(verbose_name=_('标签颜色'), max_length=60, blank=True) + + class Meta: + ordering = ('-uid',) + verbose_name = "Tags" + verbose_name_plural = _("Tags列表") + +class RustDeskTagAdmin(admin.ModelAdmin): + list_display = ('tag_name', 'uid', 'tag_color') + search_fields = ('tag_name', 'uid') + list_filter = ('uid', ) + + +class RustDeskPeer(models.Model): + ''' Pees + ''' + uid = models.CharField(verbose_name=_('用户ID'), max_length=16) + rid = models.CharField(verbose_name=_('客户端ID'), max_length=60) + username = models.CharField(verbose_name=_('系统用户名'), max_length=20, blank=True) + hostname = models.CharField(verbose_name=_('操作系统名'), max_length=30, blank=True) + alias = models.CharField(verbose_name=_('别名'), max_length=30) + platform = models.CharField(verbose_name=_('平台'), max_length=30, blank=True) + tags = models.CharField(verbose_name=_('标签'), max_length=30, blank=True) + rhash = models.CharField(verbose_name=_('设备链接密码'), max_length=60, blank=True) + ip = models.CharField(verbose_name=_('IP Address'), max_length=16, default="", blank=True) + + class Meta: + ordering = ('-username',) + verbose_name = "Peers" + verbose_name_plural = _("Peers列表" ) + + +class RustDeskPeerAdmin(admin.ModelAdmin): + list_display = ('rid', 'uid', 'username', 'hostname', 'platform', 'alias', 'tags', 'ip') + search_fields = ('deviceid', 'alias') + list_filter = ('rid', 'uid', ) + + +class RustDesDevice(models.Model): + rid = models.CharField(verbose_name=_('客户端ID'), max_length=60, blank=True) + cpu = models.CharField(verbose_name='CPU', max_length=100) + hostname = models.CharField(verbose_name=_('主机名'), max_length=100) + memory = models.CharField(verbose_name=_('内存'), max_length=100) + os = models.CharField(verbose_name=_('操作系统'), max_length=100) + uuid = models.CharField(verbose_name='uuid', max_length=100) + username = models.CharField(verbose_name=_('系统用户名'), max_length=100, blank=True) + version = models.CharField(verbose_name=_('客户端版本'), max_length=100) + create_time = models.DateTimeField(verbose_name=_('设备注册时间'), auto_now_add=True) + update_time = models.DateTimeField(verbose_name=('设备更新时间'), auto_now=True, blank=True) + ip = models.CharField(verbose_name=_('IP Address'), max_length=16, default="") + + class Meta: + ordering = ('-rid',) + verbose_name = _("设备") + verbose_name_plural = _("设备列表" ) + +class RustDesDeviceAdmin(admin.ModelAdmin): + list_display = ('rid', 'hostname', 'memory', 'uuid', 'version', 'create_time', 'update_time', 'ip') + search_fields = ('hostname', 'memory') + list_filter = ('rid', ) + +class ShareLink(models.Model): + ''' Share link + ''' + uid = models.CharField(verbose_name=_('用户ID'), max_length=16) + shash = models.CharField(verbose_name=_('链接Key'), max_length=60) + peers = models.CharField(verbose_name=_('机器ID列表'), max_length=20) + is_used = models.BooleanField(verbose_name=_('是否使用'), default=False) + is_expired = models.BooleanField(verbose_name=_('是否过期'), default=False) + create_time = models.DateTimeField(verbose_name=_('生成时间'), auto_now_add=True) + + + + class Meta: + ordering = ('-create_time',) + verbose_name = _("分享链接") + verbose_name_plural = _("链接列表" ) + + +class ShareLinkAdmin(admin.ModelAdmin): + list_display = ('shash', 'uid', 'peers', 'is_used', 'is_expired', 'create_time') + search_fields = ('peers', ) + list_filter = ('is_used', 'uid', 'is_expired' ) + +class GithubRun(models.Model): + id = models.IntegerField(verbose_name="ID",primary_key=True) + uuid = models.CharField(verbose_name="uuid", max_length=100) + status = models.CharField(verbose_name="status", max_length=100) \ No newline at end of file diff --git a/api/templates/add_peer.html b/api/templates/add_peer.html new file mode 100644 index 0000000..5161096 --- /dev/null +++ b/api/templates/add_peer.html @@ -0,0 +1,28 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk{% endblock %} +{% block legend_name %}{{ "Add Peer" | translate }}{% endblock %} +{% block content %} +
+
+
+
+
{{ "Add Client" }}
+
+
+ + {{ form.clientID }}

+ + {{ form.alias }}

+ + {{ form.tags }}

+ +
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/assign_peer.html b/api/templates/assign_peer.html new file mode 100644 index 0000000..7e156f9 --- /dev/null +++ b/api/templates/assign_peer.html @@ -0,0 +1,29 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk{% endblock %} +{% block legend_name %}{{ "Add Peer" | translate }}{% endblock %} +{% block content %} +
+
+
+
+
{{ "Assign Client to User" }}
+
+
+ {{ form.uid }}

+ + {{ form.clientID }}

+ + {{ form.alias }}

+ + {{ form.tags }}

+ +
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/base.html b/api/templates/base.html new file mode 100644 index 0000000..10ad2e9 --- /dev/null +++ b/api/templates/base.html @@ -0,0 +1,75 @@ +{% load static %} +{% load i18n %} + + + + + + {% block title %}{% endblock %} + + + + + {% block link %}{% endblock %} + + + + + + + + +
+ {% block legend_name %}{% endblock %} +
+ {% block content %}{% endblock %} + + + \ No newline at end of file diff --git a/api/templates/base_phone.html b/api/templates/base_phone.html new file mode 100644 index 0000000..84a7a4f --- /dev/null +++ b/api/templates/base_phone.html @@ -0,0 +1,84 @@ +{% load static %} +{% load i18n %} + + + + + + {% block title %}{% endblock %} + + + + + {% block link %}{% endblock %} + + + + + +
+ +
+
+
+ {% block legend_name %}{% endblock %} +
+ {% block content %}{% endblock %} + + + \ No newline at end of file diff --git a/api/templates/clients.html b/api/templates/clients.html new file mode 100644 index 0000000..2c12110 --- /dev/null +++ b/api/templates/clients.html @@ -0,0 +1,89 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Client Downloads" | translate }}{% endblock %} +{% load static %} +{% block content %} + + + + +Client Generator +
+
+

Github Clients

+ + + + + + + + {% for filename, fileinfo in client_files.items %} + + + + + {% endfor %} + +
FileDate
{{filename}}{{fileinfo.modified}}
+
+
+

Custom Clients

+ + + + + + + + {% for filename, fileinfo in client_custom_files.items %} + + + + + + {% endfor %} + +
FileDate
{{filename}}{{fileinfo.modified}}Delete
+
+
+{% endblock %} \ No newline at end of file diff --git a/api/templates/edit_peer.html b/api/templates/edit_peer.html new file mode 100644 index 0000000..8f675cd --- /dev/null +++ b/api/templates/edit_peer.html @@ -0,0 +1,32 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk{% endblock %} +{% block legend_name %}{{ "Edit Peer" | translate }}{% endblock %} +{% block content %} +{{peer.rid}} +
+
+
+
+
{{ "Edit Peer" }} {{ peer.rid }}
+
+
+ + {{ form.clientID }}

+ + {{ form.alias }}

+ + {{ form.tags }}

+ + {{ form.username }}

+ + {{ form.hostname }}

+ + {{ form.platform }}

+ +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/api/templates/generated.html b/api/templates/generated.html new file mode 100644 index 0000000..76f0d22 --- /dev/null +++ b/api/templates/generated.html @@ -0,0 +1,7 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Client Generator" | translate }}{% endblock %} +{% block content %} +{{filename}} +{% endblock %} \ No newline at end of file diff --git a/api/templates/generator.html b/api/templates/generator.html new file mode 100644 index 0000000..e799a92 --- /dev/null +++ b/api/templates/generator.html @@ -0,0 +1,253 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Client Generator" | translate }}{% endblock %} +{% block content %} + + + + + +

RustDesk Custom Client Builder

+
+
+

Select Platform

+
+ + + +
+ + + {{ form.version }} + +
+
+
+
+

General

+ + {{ form.exename }}

+ + {{ form.appname }}

+ + {{ form.direction }}

+ + {{ form.installation }}

+ + {{ form.settings }}

+
+ +
+

Custom Server

+ + {{ form.serverIP }}

+ + {{ form.key }}

+ + {{ form.apiServer }}

+ + {{ form.urlLink }}

+
+
+
+
+

Security

+ + {{ form.runasadmin }}

+ + {{ form.passApproveMode }}

+ + {{ form.permanentPassword }} *The password is used as default, but can be changed by the client

+ + +
+ +
+ +
+
+ +
+

Visual

+ + {{ form.iconfile }}

+ +


+ + {{ form.logofile }}

+ +


+ + {{ form.theme }} {{ form.themeDorO }} *Default sets the theme but allows the client to change it, Override sets the theme permanently.

+
+
+
+
+

Permissions

+ The following Permissions can be set as default (the user can change the settins) or override (the settings cannot be changed).
+ {{ form.permissionsDorO }} + + {{ form.permissionsType }}

+
+ + + + + + + + + +
+
+ +
+

Other

+
+
+ {{ form.defaultManual }}

+
+ {{ form.overrideManual }}

+
+
+
+
+ +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/api/templates/login.html b/api/templates/login.html new file mode 100644 index 0000000..3193cc9 --- /dev/null +++ b/api/templates/login.html @@ -0,0 +1,70 @@ +{% load static %} +{% load my_filters %} + + + + + + {{ "登录" | translate }}_【RustDeskWeb】 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/templates/msg.html b/api/templates/msg.html new file mode 100644 index 0000000..5666191 --- /dev/null +++ b/api/templates/msg.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load my_filters %} +{% block title %}{{title}}{% endblock %} +{% block legend_name %}{{ "信息" | translate }}{% endblock %} +{% block content %} +
+
+{% autoescape off %} + {{msg}} + {% endautoescape %} +
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/reg.html b/api/templates/reg.html new file mode 100644 index 0000000..86deaa7 --- /dev/null +++ b/api/templates/reg.html @@ -0,0 +1,144 @@ +{% load static %} +{% load my_filters %} + + + + + + + + {{ "注册" | translate }}_【RustDeskWeb】 + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/templates/share.html b/api/templates/share.html new file mode 100644 index 0000000..3995fde --- /dev/null +++ b/api/templates/share.html @@ -0,0 +1,106 @@ + +{% extends "base.html" %}{% load static %} +{% load my_filters %} +{% block title %}{{ "分享机器" | translate }}{% endblock %} +{% block link %}{% endblock %} +{% block legend_name %}{{ "分享机器给其他用户" | translate }}{% endblock %} +{% block content %} + + +
+
+
{{ "请将要分享的机器调整到右侧" | translate }}
+
+ +
+
{{ "1、链接有效期为15分钟,切勿随意分享给他人。" | translate }}
+
{{ "2、所分享的机器,被分享人享有相同的权限,如果机器设置了保存密码,被分享人也可以直接连接。" | translate }}
+
{{ "3、为保障安全,链接有效期为15分钟、链接仅有效1次。链接一旦被(非分享人的登录用户)访问,分享生效,后续访问链接失效。" | translate }}
+ +
+ + + + + + + + + + + + + + + + + {% for one in sharelinks %} + + + + + + + {% endfor %} + +
{{ "链接地址" | translate }}{{ "创建时间" | translate }}{{ "ID列表" | translate }}
/{{one.shash}} {{one.create_time}} {{one.peers}}
+
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/api/templates/show_conn_log.html b/api/templates/show_conn_log.html new file mode 100644 index 0000000..c190474 --- /dev/null +++ b/api/templates/show_conn_log.html @@ -0,0 +1,66 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Connection Log" | translate }}{% endblock %} +{% load static %} +{% block content %} + +
+
+
+ +
+
{{ "Connection Log" }}:【{{u.username}}】
+
+ + + + + + + + + + + + + + + {% for one in page_obj %} + + + + + + + + + + + {% endfor %} + +
User IPUser IDUser AliasRemote IDRemote AliasConnection Start TimeConnection End TimeDuration (HH:MM:SS)
{{one.from_ip}}{{one.from_id}}{{one.from_alias}}{{one.rid}}{{one.alias}}{{one.conn_start}}{{one.conn_end}}{{one.duration}}
+
+
+
+
+ + {% if page_obj.has_previous %} + + + {% endif %} + {% if page_obj.paginator.num_pages > 1 %} + + {{ "页码" | translate }} {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + + {% endif %} + {% if page_obj.has_next %} + + + {% endif %} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/show_file_log.html b/api/templates/show_file_log.html new file mode 100644 index 0000000..5c9a831 --- /dev/null +++ b/api/templates/show_file_log.html @@ -0,0 +1,72 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "File Transfer Log" | translate }}{% endblock %} +{% load static %} +{% block content %} + +
+
+
+ +
+
{{ "File Transfer Log" }}:【{{u.username}}】
+
+ + + + + + + + + + + + + + + + {% for one in page_obj %} + + + + + + + + + {% if one.direction == 0 %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
FileRemote IDRemote AliasUser IDUser AliasUser IPFilesizeSent/ReceivedLogged At
{{one.file}}{{one.remote_id}} {{one.remote_alias}}{{one.user_id}}{{one.user_alias}}{{one.user_ip}}{{one.filesize}}User Received FileUser Sent File{{one.logged_at}}
+
+
+
+
+ + {% if page_obj.has_previous %} + + + {% endif %} + {% if page_obj.paginator.num_pages > 1 %} + + {{ "页码" | translate }} {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + + {% endif %} + {% if page_obj.has_next %} + + + {% endif %} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/show_sys_info.html b/api/templates/show_sys_info.html new file mode 100644 index 0000000..471c430 --- /dev/null +++ b/api/templates/show_sys_info.html @@ -0,0 +1,14 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Server Information" | translate }}{% endblock %} +{% block content %} +
Hostname: +{{ hostname }} +
CPU Usage: +{{ cpu_usage }} +
Memory Usage: +{{ memory_usage }} +
Disk Usage: +{{ disk_usage }} +{% endblock %} \ No newline at end of file diff --git a/api/templates/show_work.html b/api/templates/show_work.html new file mode 100644 index 0000000..48135c5 --- /dev/null +++ b/api/templates/show_work.html @@ -0,0 +1,161 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Devices" | translate }}{% endblock %} +{% load static %} +{% block content %} + + +
+
+ {% if not show_all %} +
+ +
+
{{ online_count_single }}/{{page_obj.paginator.count }}: {{ "User Devices Online" | translate }} - 【{{ "用户名" | translate }}:{{u.username}}】 + Add Client +
+
+ + + + + + + + + + + + + + + + + + + + + + {% for one in page_obj %} + + + + + + + + + + + + + + + + + + {% endfor %} + +
Edit / Delete{{ "客户端ID" | translate }}{{ "状态" | translate }}{{ "别名" | translate }}{{ "版本" | translate }}{{ "连接密码" | translate }}{{ "系统用户名" | translate }}{{ "计算机名" | translate }}{{ "平台" | translate }}{{ "系统" | translate }}{{ "CPU" | translate }}{{ "内存" | translate }}{{ "注册时间" | translate }}{{ "更新时间" | translate }}{{ "IP Address" | translate }}
/ {{one.rid}} {{one.status}} {{one.alias}}{{one.version}}{{one.has_rhash}}{{one.username}}{{one.hostname}}{{one.platform}}{{one.os}}{{one.cpu}}{{one.memory}}{{one.create_time}}{{one.update_time}}{{one.ip}}
+
+
+
+
+ + {% if page_obj.has_previous %} + + + {% endif %} + {% if page_obj.paginator.num_pages > 1 %} + + {{ "页码" | translate }} {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + + {% endif %} + {% if page_obj.has_next %} + + + {% endif %} + +
+ {% endif %} + + + {% if u.is_admin and show_all %} +
+ +
+
{{ online_count_all }}/{{page_obj.paginator.count }}: {{ "All Devices" | translate }} » + +
+
+ + + + + + + + + + + + + + + + + + + + {% for one in page_obj %} + + + {% if one.rust_user|length > 0 %} + + {% else %} + + {% endif %} + + + + + + + + + + + + {% endfor %} + +
{{ "客户端ID" | translate }}{{ "所属用户" | translate }}{{ "版本" | translate }}{{ "系统用户名" | translate }}{{ "计算机名" | translate }}{{ "系统" | translate }}{{ "CPU" | translate }}{{ "内存" | translate }}{{ "注册日期" | translate }}{{ "更新时间" | translate }}{{ "状态" | translate }}{{ "IP Address" | translate }}
{{one.rid}} {{one.rust_user}} Assign Peer{{one.version}} {{one.username}} {{one.hostname}} {{one.os}} {{one.cpu}} {{one.memory}} {{one.create_time}} {{one.update_time}} {{one.status}} {{one.ip}}
+
+
+
+
+ + {% if page_obj.has_previous %} + + + {% endif %} + {% if page_obj.paginator.num_pages > 1 %} + + {{ "页码" | translate }} {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + + {% endif %} + {% if page_obj.has_next %} + + + {% endif %} + +
+ {% endif %} + + + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/api/templates/waiting.html b/api/templates/waiting.html new file mode 100644 index 0000000..b1abc22 --- /dev/null +++ b/api/templates/waiting.html @@ -0,0 +1,13 @@ +{% extends phone_or_desktop %} +{% load my_filters %} +{% block title %}RustDesk WebUI{% endblock %} +{% block legend_name %}{{ "Client Generator" | translate }}{% endblock %} +{% block content %} +Please wait...This can take 20-30 minutes (or longer if there are other users).

+Status: {{status}} + +{% endblock %} \ No newline at end of file diff --git a/api/templatetags/__init__.py b/api/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/templatetags/my_filters.py b/api/templatetags/my_filters.py new file mode 100644 index 0000000..fd0c14a --- /dev/null +++ b/api/templatetags/my_filters.py @@ -0,0 +1,8 @@ +from django import template +from django.utils.translation import gettext as _ + +register = template.Library() + +@register.filter +def translate(text): + return _(text) \ No newline at end of file diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..9f5bd95 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,42 @@ +import django +if django.__version__.split('.')[0]>='4': + from django.urls import re_path as url +else: + from django.conf.urls import url, include + +from api import views + +urlpatterns = [ + url(r'^login',views.login), + url(r'^logout',views.logout), + url(r'^ab$',views.ab), + url(r'^ab\/get',views.ab_get), # 兼容 x86-sciter 版客户端 + url(r'^users',views.users), + url(r'^peers',views.peers), + url(r'^currentUser',views.currentUser), + url(r'^sysinfo',views.sysinfo), + url(r'^heartbeat',views.heartbeat), + #url(r'^register',views.register), + url(r'^user_action',views.user_action), # 前端 + url(r'^work',views.work), # 前端 + url(r'^down_peers$',views.down_peers), # 前端 + url(r'^share',views.share), # 前端 + url(r'^conn_log',views.conn_log), + url(r'^file_log',views.file_log), + url(r'^audit',views.audit), + url(r'^sys_info',views.sys_info), + url(r'^clients',views.clients), + url(r'^download',views.download), + url(r'^generator',views.generator_view), + url(r'^check_for_file',views.check_for_file), + url(r'^download_client',views.download_client), + url(r'^creategh',views.create_github_run), + url(r'^updategh',views.update_github_run), + url(r'^save_custom_client',views.save_custom_client), + url(r'^delete_file',views.delete_file), + url(r'^get_png',views.get_png), + url(r'^add_peer',views.add_peer), + url(r'^delete_peer',views.delete_peer), + url(r'^edit_peer',views.edit_peer), + url(r'^assign_peer',views.assign_peer), + ] diff --git a/api/util.py b/api/util.py new file mode 100644 index 0000000..2923dd0 --- /dev/null +++ b/api/util.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" +Created on Thu Nov 19 15:51:21 2020 + +@author: lenovo +""" + +import platform +import logging +from .models_user import UserProfile +logger = logging.getLogger(__name__) + +from django.conf import settings as _settings + +def settings(request): + """ + TEMPLATE_CONTEXT_PROCESSORS + """ + context = { 'settings': _settings } + try: + username = request.user + u = UserProfile.objects.get(username=username) + context['test'] = 'This is a test variable' + context['u'] = u + #context['user'] = u + context['username'] = username + context['is_admin'] = u.is_admin + context['is_active'] = u.is_active + context['domain'] = _settings.ID_SERVER + context['is_windows'] = True if platform.system() == 'Windows' else False + + + logger.info("set system status variable") + except Exception as e: + logger.error("settings:{}".format( e)) + return context \ No newline at end of file diff --git a/api/views.py b/api/views.py new file mode 100644 index 0000000..7ddaaf9 --- /dev/null +++ b/api/views.py @@ -0,0 +1,26 @@ +from django.shortcuts import render +from django.http import HttpResponseRedirect +from django.contrib.auth.hashers import make_password +from django.db.models import Q +from django.contrib.auth.decorators import login_required +from django.contrib import auth +from django.forms.models import model_to_dict + +from itertools import chain +from django.db.models.fields import DateTimeField, DateField, CharField, TextField + +from django.db.models import Model + +from django.http import JsonResponse +import json +import time +import datetime +import hashlib +from api.models import RustDeskToken, UserProfile, RustDeskTag, RustDeskPeer, RustDesDevice + +import copy + +from .views_front import * +from .views_api import * +from .views_generator import * +from .front_locale import * diff --git a/api/views_api.py b/api/views_api.py new file mode 100644 index 0000000..c9d86d2 --- /dev/null +++ b/api/views_api.py @@ -0,0 +1,329 @@ +# cython:language_level=3 +from django.http import JsonResponse +import json +import time +import datetime +import hashlib +import math +from django.contrib import auth +from django.forms.models import model_to_dict +from api.models import RustDeskToken, UserProfile, RustDeskTag, RustDeskPeer, RustDesDevice, ConnLog, FileLog +from django.db.models import Q +import copy +from .views_front import * +from django.utils.translation import gettext as _ + + +def login(request): + result = {} + if request.method == 'GET': + result['error'] = _('请求方式错误!请使用POST方式。') + return JsonResponse(result) + + data = json.loads(request.body.decode()) + + username = data.get('username', '') + password = data.get('password', '') + rid = data.get('id', '') + uuid = data.get('uuid', '') + autoLogin = data.get('autoLogin', True) + rtype = data.get('type', '') + deviceInfo = data.get('deviceInfo', '') + user = auth.authenticate(username=username,password=password) + if not user: + result['error'] = _('帐号或密码错误!请重试,多次重试后将被锁定IP!') + return JsonResponse(result) + user.rid = rid + user.uuid = uuid + user.autoLogin = autoLogin + user.rtype = rtype + user.deviceInfo = json.dumps(deviceInfo) + user.save() + + token = RustDeskToken.objects.filter(Q(uid=user.id) & Q(username=user.username) & Q(rid=user.rid)).first() + + # Check whether + if token: + now_t = datetime.datetime.now() + nums = (now_t - token.create_time).seconds if now_t > token.create_time else 0 + if nums >= EFFECTIVE_SECONDS: + token.delete() + token = None + + if not token: + # Get and save token + token = RustDeskToken( + username=user.username, + uid=user.id, + uuid=user.uuid, + rid=user.rid, + access_token=getStrMd5(str(time.time())+salt) + ) + token.save() + + result['access_token'] = token.access_token + result['type'] = 'access_token' + result['user'] = {'name':user.username} + return JsonResponse(result) + + +def logout(request): + if request.method == 'GET': + result = {'error':_('请求方式错误!')} + return JsonResponse(result) + + data = json.loads(request.body.decode()) + rid = data.get('id', '') + uuid = data.get('uuid', '') + user = UserProfile.objects.filter(Q(rid=rid) & Q(uuid=uuid)).first() + if not user: + result = {'error':_('异常请求!')} + return JsonResponse(result) + token = RustDeskToken.objects.filter(Q(uid=user.id) & Q(rid=user.rid)).first() + if token: + token.delete() + + result = {'code':1} + return JsonResponse(result) + + +def currentUser(request): + result = {} + if request.method == 'GET': + result['error'] = _('错误的提交方式!') + return JsonResponse(result) + postdata = json.loads(request.body) + rid = postdata.get('id', '') + uuid = postdata.get('uuid', '') + + access_token = request.META.get('HTTP_AUTHORIZATION', '') + access_token = access_token.split('Bearer ')[-1] + token = RustDeskToken.objects.filter(Q(access_token=access_token) ).first() + user = None + if token: + user = UserProfile.objects.filter(Q(id=token.uid)).first() + + if user: + if token: + result['access_token'] = token.access_token + result['type'] = 'access_token' + result['name'] = user.username + return JsonResponse(result) + + +def ab(request): + ''' + ''' + access_token = request.META.get('HTTP_AUTHORIZATION', '') + access_token = access_token.split('Bearer ')[-1] + token = RustDeskToken.objects.filter(Q(access_token=access_token) ).first() + if not token: + result = {'error':_('拉取列表错误!')} + return JsonResponse(result) + + if request.method == 'GET': + result = {} + uid = token.uid + tags = RustDeskTag.objects.filter(Q(uid=uid) ) + tag_names = [] + tag_colors = {} + if tags: + tag_names = [str(x.tag_name) for x in tags] + tag_colors = {str(x.tag_name):int(x.tag_color) for x in tags if x.tag_color!=''} + + peers_result = [] + peers = RustDeskPeer.objects.filter(Q(uid=uid) ) + if peers: + for peer in peers: + tmp = { + 'id':peer.rid, + 'username':peer.username, + 'hostname':peer.hostname, + 'alias':peer.alias, + 'platform':peer.platform, + 'tags':peer.tags.split(','), + 'hash':peer.rhash, + } + peers_result.append(tmp) + + result['updated_at'] = datetime.datetime.now() + result['data'] = { + 'tags':tag_names, + 'peers':peers_result, + 'tag_colors':json.dumps(tag_colors) + } + result['data'] = json.dumps(result['data']) + return JsonResponse(result) + else: + postdata = json.loads(request.body.decode()) + data = postdata.get('data', '') + data = {} if data=='' else json.loads(data) + tagnames = data.get('tags', []) + tag_colors = data.get('tag_colors', '') + tag_colors = {} if tag_colors=='' else json.loads(tag_colors) + peers = data.get('peers', []) + + if tagnames: + # Delete the old tag + RustDeskTag.objects.filter(uid=token.uid).delete() + # Increase + newlist = [] + for name in tagnames: + tag = RustDeskTag( + uid=token.uid, + tag_name=name, + tag_color=tag_colors.get(name, '') + ) + newlist.append(tag) + RustDeskTag.objects.bulk_create(newlist) + if peers: + RustDeskPeer.objects.filter(uid=token.uid).delete() + newlist = [] + for one in peers: + peer = RustDeskPeer( + uid=token.uid, + rid=one['id'], + username=one['username'], + hostname=one['hostname'], + alias=one['alias'], + platform=one['platform'], + tags=','.join(one['tags']), + rhash=one['hash'], + + + ) + newlist.append(peer) + RustDeskPeer.objects.bulk_create(newlist) + + result = { + 'code':102, + 'data':_('更新地址簿有误') + } + return JsonResponse(result) + +def ab_get(request): + # 兼容 x86-sciter 版客户端,此版客户端通过访问 "POST /api/ab/get" 来获取地址簿 + request.method = 'GET' + return ab(request) + +def sysinfo(request): + # 客户端注册服务后,才会发送设备信息 + result = {} + if request.method == 'GET': + result['error'] = _('错误的提交方式!') + return JsonResponse(result) + + client_ip = get_client_ip(request) + postdata = json.loads(request.body) + device = RustDesDevice.objects.filter(Q(rid=postdata['id']) & Q(uuid=postdata['uuid']) ).first() + if not device: + device = RustDesDevice( + rid=postdata['id'], + cpu=postdata['cpu'], + hostname=postdata['hostname'], + memory=postdata['memory'], + os=postdata['os'], + username=postdata.get('username', '-'), + uuid=postdata['uuid'], + version=postdata['version'], + ip=client_ip, + ) + device.save() + else: + postdata2 = copy.copy(postdata) + postdata2['rid'] = postdata2['id'] + postdata2.pop('id') + postdata2['ip'] = client_ip + RustDesDevice.objects.filter(Q(rid=postdata['id']) & Q(uuid=postdata['uuid']) ).update(**postdata2) + result['data'] = 'ok' + return JsonResponse(result) + +def heartbeat(request): + postdata = json.loads(request.body) + device = RustDesDevice.objects.filter(Q(rid=postdata['id']) & Q(uuid=postdata['uuid']) ).first() + if device: + device.save() + # token保活 + create_time = datetime.datetime.now() + datetime.timedelta(seconds=EFFECTIVE_SECONDS) + RustDeskToken.objects.filter(Q(rid=postdata['id']) & Q(uuid=postdata['uuid']) ).update(create_time=create_time) + result = {} + result['data'] = _('在线') + return JsonResponse(result) + +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +def convert_filesize(size_bytes): + if size_bytes == 0: + return "0B" + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return "%s %s" % (s, size_name[i]) + + +def audit(request): + postdata = json.loads(request.body) + #print(postdata) + audit_type = postdata['action'] if 'action' in postdata else '' + if audit_type == 'new': + new_conn_log = ConnLog( + action=postdata['action'] if 'action' in postdata else '', + conn_id=postdata['conn_id'] if 'conn_id' in postdata else 0, + from_ip=postdata['ip'] if 'ip' in postdata else '', + from_id='', + rid=postdata['id'] if 'id' in postdata else '', + conn_start=datetime.datetime.now(), + session_id=postdata['session_id'] if 'session_id' in postdata else 0, + uuid=postdata['uuid'] if 'uuid' in postdata else '', + ) + new_conn_log.save() + elif audit_type =="close": + ConnLog.objects.filter(Q(conn_id=postdata['conn_id'])).update(conn_end=datetime.datetime.now()) + elif 'is_file' in postdata: + print(postdata) + files = json.loads(postdata['info'])['files'] + filesize = convert_filesize(int(files[0][1])) + new_file_log = FileLog( + file=postdata['path'], + user_id=postdata['peer_id'], + user_ip=json.loads(postdata['info'])['ip'], + remote_id=postdata['id'], + filesize=filesize, + direction=postdata['type'], + logged_at=datetime.datetime.now(), + ) + new_file_log.save() + else: + try: + peer = postdata['peer'] + ConnLog.objects.filter(Q(conn_id=postdata['conn_id'])).update(session_id=postdata['session_id']) + ConnLog.objects.filter(Q(conn_id=postdata['conn_id'])).update(from_id=peer[0]) + except: + print(postdata) + + result = { + 'code':1, + 'data':'ok' + } + return JsonResponse(result) + +def users(request): + result = { + 'code':1, + 'data':_('好的') + } + return JsonResponse(result) + +def peers(request): + result = { + 'code':1, + 'data':'ok' + } + return JsonResponse(result) \ No newline at end of file diff --git a/api/views_front.py b/api/views_front.py new file mode 100644 index 0000000..10a10d8 --- /dev/null +++ b/api/views_front.py @@ -0,0 +1,719 @@ +# cython:language_level=3 +from pathlib import Path +from django.shortcuts import render +from django.http import HttpResponseRedirect +from django.contrib.auth.hashers import make_password +from django.http import JsonResponse +from django.db.models import Q +from django.contrib.auth.decorators import login_required +from django.contrib import auth +from api.models import RustDeskPeer, RustDesDevice, UserProfile, ShareLink, ConnLog, FileLog +from django.forms.models import model_to_dict +from django.core.paginator import Paginator +from django.http import HttpResponse +from django.conf import settings + +from itertools import chain +from django.db.models.fields import DateTimeField, DateField, CharField, TextField +import datetime +from django.db.models import Model +import json +import time +import hashlib +import sys +from dateutil import tz +import platform +import psutil +import os + +from io import BytesIO +import xlwt +from django.utils.translation import gettext as _ +from .forms import AddPeerForm, EditPeerForm, AssignPeerForm + +BASE_DIR = Path(__file__).resolve().parent.parent +salt = 'xiaomo' +EFFECTIVE_SECONDS = 7200 + +def getStrMd5(s): + if not isinstance(s, (str,)): + s = str(s) + + myHash = hashlib.md5() + myHash.update(s.encode()) + + return myHash.hexdigest() + +def model_to_dict2(instance, fields=None, exclude=None, replace=None, default=None): + """ + :params instance: Model object, not the QuerySet data set + :params fields: Specify the field data to be displayed,('Field 1','Field 2') + :params exclude: Specify the field data that is eliminated,('Field 1','Field 2') + :params replace: Modify the field name to the required name,{'Database field name':'Front -end display name'} + :params default: Added no existing field data,{'Field':'data'} + """ + # 对传递进来的模型对象校验 + if not isinstance(instance, Model): + raise Exception(_('model_to_dict接收的参数必须是模型对象')) + # 对替换数据库字段名字校验 + if replace and type(replace) == dict: + for replace_field in replace.values(): + if hasattr(instance, replace_field): + raise Exception(_(f'model_to_dict,要替换成{replace_field}字段已经存在了')) + # 对要新增的默认值进行校验 + if default and type(default) == dict: + for default_key in default.keys(): + if hasattr(instance, default_key): + raise Exception(_(f'model_to_dict,要新增默认值,但字段{default_key}已经存在了')) + opts = instance._meta + data = {} + for f in chain(opts.concrete_fields, opts.private_fields, opts.many_to_many): + # 源码下:这块代码会将时间字段剔除掉,我加上一层判断,让其不再剔除时间字段 + if not getattr(f, 'editable', False): + if type(f) == DateField or type(f) == DateTimeField: + pass + else: + continue + # 如果fields参数传递了,要进行判断 + if fields is not None and f.name not in fields: + continue + # 如果exclude 传递了,要进行判断 + if exclude and f.name in exclude: + continue + + key = f.name + # 获取字段对应的数据 + if type(f) == DateTimeField: + # 字段类型是,DateTimeFiled 使用自己的方式操作 + value = getattr(instance, key) + value = datetime.datetime.strftime(value, '%Y-%m-%d %H:%M') + elif type(f) == DateField: + # 字段类型是,DateFiled 使用自己的方式操作 + value = getattr(instance, key) + value = datetime.datetime.strftime(value, '%Y-%m-%d') + elif type(f) == CharField or type(f) == TextField: + # 字符串数据是否可以进行序列化,转成python结构数据 + value = getattr(instance, key) + try: + value = json.loads(value) + except Exception as _: + value = value + else:#其他类型的字段 + # value = getattr(instance, key) + key = f.name + value = f.value_from_object(instance) + # data[f.name] = f.value_from_object(instance) + # 1、替换字段名字 + if replace and key in replace.keys(): + key = replace.get(key) + data[key] = value + #2、新增默认的字段数据 + if default: + data.update(default) + return data + + +def index(request): + print('sdf',sys.argv) + if request.user and request.user.username!='AnonymousUser': + return HttpResponseRedirect('/api/work') + return HttpResponseRedirect('/api/user_action?action=login') + + +def user_action(request): + action = request.GET.get('action', '') + if action == 'login': + return user_login(request) + elif action == 'register': + return user_register(request) + elif action == 'logout': + return user_logout(request) + else: + return + +def user_login(request): + if request.method == 'GET': + return render(request, 'login.html') + + username = request.POST.get('account', '') + password = request.POST.get('password', '') + if not username or not password: + return JsonResponse({'code':0, 'msg':_('出了点问题,未获取用户名或密码。')}) + + user = auth.authenticate(username=username,password=password) + if user: + auth.login(request, user) + return JsonResponse({'code':1, 'url':'/api/work'}) + else: + return JsonResponse({'code':0, 'msg':_('帐号或密码错误!')}) + +def user_register(request): + info = '' + if request.method == 'GET': + return render(request, 'reg.html') + ALLOW_REGISTRATION = settings.ALLOW_REGISTRATION + result = { + 'code':0, + 'msg':'' + } + if not ALLOW_REGISTRATION: + result['msg'] = _('当前未开放注册,请联系管理员!') + return JsonResponse(result) + + username = request.POST.get('user', '') + password1 = request.POST.get('pwd', '') + + if len(username) <= 3: + info = _('用户名不得小于3位') + result['msg'] = info + return JsonResponse(result) + + if len(password1)<8 or len(password1)>20: + info = _('密码长度不符合要求, 应在8~20位。') + result['msg'] = info + return JsonResponse(result) + + user = UserProfile.objects.filter(Q(username=username)).first() + if user: + info = _('用户名已存在。') + result['msg'] = info + return JsonResponse(result) + user = UserProfile( + username=username, + password=make_password(password1), + is_admin = True if UserProfile.objects.count()==0 else False, + is_superuser = True if UserProfile.objects.count()==0 else False, + is_active = True + ) + user.save() + result['msg'] = info + result['code'] = 1 + return JsonResponse(result) + +@login_required(login_url='/api/user_action?action=login') +def user_logout(request): + info = '' + auth.logout(request) + return HttpResponseRedirect('/api/user_action?action=login') + +def get_single_info(uid): + online_count = 0 + peers = RustDeskPeer.objects.filter(Q(uid=uid)) + rids = [x.rid for x in peers] + peers = {x.rid:model_to_dict(x) for x in peers} + #print(peers) + devices = RustDesDevice.objects.filter(rid__in=rids) + devices = {x.rid:x for x in devices} + + for rid in peers.keys(): + peers[rid]['has_rhash'] = _('yes') if len(peers[rid]['rhash'])>1 else _('no') + peers[rid]['status'] = _('X') + + now = datetime.datetime.now() + for rid, device in devices.items(): + peers[rid]['create_time'] = device.create_time.strftime('%Y-%m-%d') + peers[rid]['update_time'] = device.update_time.strftime('%Y-%m-%d %H:%M') + peers[rid]['version'] = device.version + peers[rid]['memory'] = device.memory + peers[rid]['cpu'] = device.cpu + peers[rid]['os'] = device.os + peers[rid]['ip'] = device.ip + if (now-device.update_time).seconds <=120: + peers[rid]['status'] = _('Online') + online_count += 1 + else: + peers[rid]['status'] = _('X') + + sorted_peers = sorted(peers.items(), key=custom_sort, reverse=True) + new_ordered_dict = {} + for key, peer in sorted_peers: + new_ordered_dict[key] = peer + + #return ([v for k,v in peers.items()], online_count) + return ([v for k,v in new_ordered_dict.items()], online_count) + +def get_all_info(): + online_count = 0 + devices = RustDesDevice.objects.all() + peers = RustDeskPeer.objects.all() + devices = {x.rid:model_to_dict2(x) for x in devices} + now = datetime.datetime.now() + for peer in peers: + user = UserProfile.objects.filter(Q(id=peer.uid)).first() + device = devices.get(peer.rid, None) + if device: + devices[peer.rid]['rust_user'] = user.username + + for k, v in devices.items(): + if (now-datetime.datetime.strptime(v['update_time'], '%Y-%m-%d %H:%M')).seconds <=120: + devices[k]['status'] = _('Online') + online_count += 1 + else: + devices[k]['status'] = _('X') + + sorted_devices = sorted(devices.items(), key=custom_sort, reverse=True) + new_ordered_dict = {} + for key, device in sorted_devices: + new_ordered_dict[key] = device + return ([v for k,v in new_ordered_dict.items()], online_count) + +def custom_sort(item): + status = item[1]['status'] + if status == 'Online': + return 1 + else: + return 0 + +def get_conn_log(): + logs = ConnLog.objects.all() + logs = {x.id:model_to_dict(x) for x in logs} + + for k, v in logs.items(): + try: + peer = RustDeskPeer.objects.get(rid=v['rid']) + logs[k]['alias'] = peer.alias + except: + logs[k]['alias'] = 'UNKNOWN' + try: + peer = RustDeskPeer.objects.get(rid=v['from_id']) + logs[k]['from_alias'] = peer.alias + except: + logs[k]['from_alias'] = 'UNKNOWN' + #from_zone = tz.tzutc() + #to_zone = tz.tzlocal() + #utc = logs[k]['logged_at'] + #utc = utc.replace(tzinfo=from_zone) + #logs[k]['logged_at'] = utc.astimezone(to_zone) + try: + duration = round((logs[k]['conn_end'] - logs[k]['conn_start']).total_seconds()) + m, s = divmod(duration, 60) + h, m = divmod(m, 60) + #d, h = divmod(h, 24) + logs[k]['duration'] = f'{h:02d}:{m:02d}:{s:02d}' + except: + logs[k]['duration'] = -1 + + sorted_logs = sorted(logs.items(), key=lambda x: x[1]['conn_start'], reverse=True) + new_ordered_dict = {} + for key, alog in sorted_logs: + new_ordered_dict[key] = alog + + return [v for k, v in new_ordered_dict.items()] + +def get_file_log(): + logs = FileLog.objects.all() + logs = {x.id:model_to_dict(x) for x in logs} + + for k, v in logs.items(): + try: + peer_remote = RustDeskPeer.objects.get(rid=v['remote_id']) + logs[k]['remote_alias'] = peer_remote.alias + except: + logs[k]['remote_alias'] = 'UNKNOWN' + try: + peer_user = RustDeskPeer.objects.get(rid=v['user_id']) + logs[k]['user_alias'] = peer_user.alias + except: + logs[k]['user_alias'] = 'UNKNOWN' + + sorted_logs = sorted(logs.items(), key=lambda x: x[1]['logged_at'], reverse=True) + new_ordered_dict = {} + for key, alog in sorted_logs: + new_ordered_dict[key] = alog + + return [v for k, v in new_ordered_dict.items()] + +@login_required(login_url='/api/user_action?action=login') +def sys_info(request): + hostname = platform.node() + cpu_usage = psutil.cpu_percent() + memory_usage = psutil.virtual_memory().percent + disk_usage = psutil.disk_usage('/').percent + print(cpu_usage, memory_usage, disk_usage) + return render(request, 'show_sys_info.html', {'hostname':hostname, 'cpu_usage':cpu_usage, 'memory_usage':memory_usage, 'disk_usage':disk_usage, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def clients(request): + basedir = os.path.join('clients') + androidaarch64 = os.path.join(basedir,'android','aarch64') + androidarmv7 = os.path.join(basedir,'android','armv7') + linuxaarch64 = os.path.join(basedir,'linux','aarch64') + linuxx86_64 = os.path.join(basedir,'linux','x86_64') + mocos = os.path.join(basedir,'macOS') + sciter = os.path.join(basedir,'sciter') + custom = os.path.join(basedir,'custom') + client_files = {} + client_custom_files = {} + if os.path.exists(basedir): + for file in os.listdir(basedir): + if (file.endswith(".exe") or file.endswith(".msi")): + filepath = os.path.join(basedir,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': basedir + } + if os.path.exists(androidaarch64): + for file in os.listdir(androidaarch64): + if file.endswith(".apk"): + filepath = os.path.join(androidaarch64,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': androidaarch64 + } + if os.path.exists(androidarmv7): + for file in os.listdir(androidarmv7): + if file.endswith(".apk"): + filepath = os.path.join(androidarmv7,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': androidarmv7 + } + if os.path.exists(linuxaarch64): + for file in os.listdir(linuxaarch64): + if (file.endswith(".rpm") or file.endswith(".deb")): + filepath = os.path.join(linuxaarch64,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': linuxaarch64 + } + if os.path.exists(linuxx86_64): + for file in os.listdir(linuxx86_64): + if (file.endswith(".rpm") or file.endswith(".deb")): + filepath = os.path.join(linuxx86_64,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': linuxx86_64 + } + if os.path.exists(mocos): + for file in os.listdir(mocos): + if file.endswith(".dmg"): + filepath = os.path.join(mocos,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': mocos + } + if os.path.exists(sciter): + for file in os.listdir(sciter): + if file.endswith(".exe"): + filepath = os.path.join(sciter,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_files[file] = { + 'file': file, + 'modified': modified, + 'path': sciter + } + if os.path.exists(custom): + for file in os.listdir(custom): + #if file.endswith(".exe"): + filepath = os.path.join(custom,file) + modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %I:%M:%S %p') + client_custom_files[file] = { + 'file': file, + 'modified': modified, + 'path': custom + } + return render(request, 'clients.html', {'client_files': client_files, 'client_custom_files': client_custom_files, 'phone_or_desktop': is_mobile(request)}) + +def download_file(request, filename, path): + file_path = os.path.join(str(BASE_DIR),path,filename) + with open(file_path, 'rb') as file: + response = HttpResponse(file, headers={ + 'Content-Type': 'application/x-binary', + 'Content-Disposition': f'attachment; filename="{filename}"' + }) + return response + +@login_required(login_url='/api/user_action?action=login') +def download(request): + filename = request.GET['filename'] + path = request.GET['path'] + return download_file(request, filename, path) + +@login_required(login_url='/api/user_cation?action=login') +def delete_file(request): + filename = request.GET['filename'] + path = request.GET['path'] + file_path = os.path.join(str(BASE_DIR),path,filename) + if os.path.isfile(file_path): + os.remove(file_path) + return HttpResponseRedirect('/api/clients') + +@login_required(login_url='/api/user_action?action=login') +def add_peer(request): + if request.method == 'POST': + form = AddPeerForm(request.POST) + if form.is_valid(): + rid = form.cleaned_data['clientID'] + uid = request.user.id + username = form.cleaned_data['username'] + hostname = form.cleaned_data['hostname'] + plat = form.cleaned_data['platform'] + alias = form.cleaned_data['alias'] + tags = form.cleaned_data['tags'] + ip = form.cleaned_data['ip'] + + peer = RustDeskPeer( + uid = uid, + rid = rid, + username = username, + hostname = hostname, + platform = plat, + alias = alias, + tags = tags, + ip = ip + ) + peer.save() + return HttpResponseRedirect('/api/work') + else: + rid = request.GET.get('rid','') + form = AddPeerForm() + return render(request, 'add_peer.html', {'form': form, 'rid': rid, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def edit_peer(request): + if request.method == 'POST': + form = EditPeerForm(request.POST) + if form.is_valid(): + rid = form.cleaned_data['clientID'] + uid = request.user.id + username = form.cleaned_data['username'] + hostname = form.cleaned_data['hostname'] + plat = form.cleaned_data['platform'] + alias = form.cleaned_data['alias'] + tags = form.cleaned_data['tags'] + + updated_peer = RustDeskPeer.objects.get(rid=rid,uid=uid) + updated_peer.username=username + updated_peer.hostname=hostname + updated_peer.platform=plat + updated_peer.alias=alias + updated_peer.tags=tags + updated_peer.save() + + return HttpResponseRedirect('/api/work') + else: + print(form.errors) + else: + rid = request.GET.get('rid','') + peer = RustDeskPeer.objects.get(rid=rid) + initial_data = { + 'clientID': rid, + 'alias': peer.alias, + 'tags': peer.tags, + 'username': peer.username, + 'hostname': peer.hostname, + 'platform': peer.platform, + 'ip': peer.ip + } + form = EditPeerForm(initial=initial_data) + return render(request, 'edit_peer.html', {'form': form, 'peer': peer, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def assign_peer(request): + if request.method == 'POST': + form = AssignPeerForm(request.POST) + if form.is_valid(): + rid = form.cleaned_data['clientID'] + uid = form.cleaned_data['uid'] + username = form.cleaned_data['username'] + hostname = form.cleaned_data['hostname'] + plat = form.cleaned_data['platform'] + alias = form.cleaned_data['alias'] + tags = form.cleaned_data['tags'] + ip = form.cleaned_data['ip'] + + peer = RustDeskPeer( + uid = uid.id, + rid = rid, + username = username, + hostname = hostname, + platform = plat, + alias = alias, + tags = tags, + ip = ip + ) + peer.save() + return HttpResponseRedirect('/api/work') + else: + print(form.errors) + else: + rid = request.GET.get('rid') + form = AssignPeerForm() + #get list of users from the database + return render(request, 'assign_peer.html', {'form':form, 'rid': rid}) + +@login_required(login_url='/api/user_action?action=login') +def delete_peer(request): + rid = request.GET.get('rid') + peer = RustDeskPeer.objects.filter(Q(uid=request.user.id) & Q(rid=rid)) + peer.delete() + return HttpResponseRedirect('/api/work') + +@login_required(login_url='/api/user_action?action=login') +def conn_log(request): + paginator = Paginator(get_conn_log(), 20) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + return render(request, 'show_conn_log.html', {'page_obj':page_obj, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def file_log(request): + paginator = Paginator(get_file_log(), 20) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + return render(request, 'show_file_log.html', {'page_obj':page_obj, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def work(request): + username = request.user + u = UserProfile.objects.get(username=username) + + show_type = request.GET.get('show_type', '') + show_all = True if show_type == 'admin' and u.is_admin else False + all_info, online_count_all = get_all_info() + single_info, online_count_single = get_single_info(u.id) + paginator = Paginator(all_info, 100) if show_type == 'admin' and u.is_admin else Paginator(single_info, 100) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + return render(request, 'show_work.html', {'u':u, 'show_all':show_all, 'page_obj':page_obj, 'online_count_single':online_count_single, 'online_count_all':online_count_all, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def down_peers(request): + username = request.user + u = UserProfile.objects.get(username=username) + + if not u.is_admin: + print(u.is_admin) + return HttpResponseRedirect('/api/work') + + all_info = get_all_info() + f = xlwt.Workbook(encoding='utf-8') + sheet1 = f.add_sheet(_(u'设备信息表'), cell_overwrite_ok=True) + all_fields = [x.name for x in RustDesDevice._meta.get_fields()] + all_fields.append('rust_user') + for i, one in enumerate(all_info): + for j, name in enumerate(all_fields): + if i == 0: + # 写入列名 + sheet1.write(i, j, name) + sheet1.write(i+1, j, one.get(name, '-')) + + sio = BytesIO() + f.save(sio) + sio.seek(0) + response = HttpResponse(sio.getvalue(), content_type='application/vnd.ms-excel') + response['Content-Disposition'] = 'attachment; filename=DeviceInfo.xls' + response.write(sio.getvalue()) + return response + +def check_sharelink_expired(sharelink): + now = datetime.datetime.now() + if sharelink.create_time > now: + return False + if (now - sharelink.create_time).seconds <15 * 60: + return False + else: + sharelink.is_expired = True + sharelink.save() + return True + + +@login_required(login_url='/api/user_action?action=login') +def share(request): + peers = RustDeskPeer.objects.filter(Q(uid=request.user.id)) + sharelinks = ShareLink.objects.filter(Q(uid=request.user.id) & Q(is_used=False) & Q(is_expired=False)) + + + # 省资源:处理已过期请求,不主动定时任务轮询请求,在任意地方请求时,检查是否过期,过期则保存。 + now = datetime.datetime.now() + for sl in sharelinks: + check_sharelink_expired(sl) + sharelinks = ShareLink.objects.filter(Q(uid=request.user.id) & Q(is_used=False) & Q(is_expired=False)) + peers = [{'id':ix+1, 'name':f'{p.rid}|{p.alias}'} for ix, p in enumerate(peers)] + sharelinks = [{'shash':s.shash, 'is_used':s.is_used, 'is_expired':s.is_expired, 'create_time':s.create_time, 'peers':s.peers} for ix, s in enumerate(sharelinks)] + + if request.method == 'GET': + url = request.build_absolute_uri() + if url.endswith('share'): + return render(request, 'share.html', {'peers':peers, 'sharelinks':sharelinks}) + else: + shash = url.split('/')[-1] + sharelink = ShareLink.objects.filter(Q(shash=shash)) + msg = '' + title = 'success' + if not sharelink: + title = 'mistake' + msg = f'Link{url}:
Share the link does not exist or have failed.' + else: + sharelink = sharelink[0] + if str(request.user.id) == str(sharelink.uid): + title = 'mistake' + msg = f'Link{url}:

Lets say, you cant share the link to yourself, right?Intersection' + else: + sharelink.is_used = True + sharelink.save() + peers = sharelink.peers + peers = peers.split(',') + # 自己的peers若重叠,需要跳过 + peers_self_ids = [x.rid for x in RustDeskPeer.objects.filter(Q(uid=request.user.id))] + peers_share = RustDeskPeer.objects.filter(Q(rid__in=peers) & Q(uid=sharelink.uid)) + peers_share_ids = [x.rid for x in peers_share] + + for peer in peers_share: + if peer.rid in peers_self_ids: + continue + #peer = RustDeskPeer.objects.get(rid=peer.rid) + peer_f = RustDeskPeer.objects.filter(Q(rid=peer.rid) & Q(uid=sharelink.uid)) + if not peer_f: + msg += f"{peer.rid}existed," + continue + + if len(peer_f) > 1: + msg += f'{peer.rid}There are multiple,Has skipped. ' + continue + peer = peer_f[0] + peer.id = None + peer.uid = request.user.id + peer.save() + msg += f"{peer.rid}," + + msg += 'Has been successfully obtained.' + + title = _(title) + msg = _(msg) + return render(request, 'msg.html', {'title':msg, 'msg':msg}) + else: + data = request.POST.get('data', '[]') + + data = json.loads(data) + if not data: + return JsonResponse({'code':0, 'msg':_('数据为空。')}) + rustdesk_ids = [x['title'].split('|')[0] for x in data] + rustdesk_ids = ','.join(rustdesk_ids) + sharelink = ShareLink( + uid=request.user.id, + shash = getStrMd5(str(time.time())+salt), + peers=rustdesk_ids, + ) + sharelink.save() + + return JsonResponse({'code':1, 'shash':sharelink.shash}) + +def is_mobile(request): + user_agent = request.META['HTTP_USER_AGENT'] + if 'Mobile' in user_agent or 'Android' in user_agent or 'iPhone' in user_agent: + return 'base_phone.html' + else: + return 'base.html' diff --git a/api/views_generator.py b/api/views_generator.py new file mode 100644 index 0000000..12a9e0f --- /dev/null +++ b/api/views_generator.py @@ -0,0 +1,343 @@ +import io +from pathlib import Path +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.core.files.base import ContentFile +import os +import re +import requests +import base64 +import json +import uuid +import pathlib +from django.conf import settings as _settings +from django.db.models import Q +from .forms import GenerateForm +from .models import GithubRun +from PIL import Image +from urllib.parse import quote + +@login_required(login_url='/api/user_action?action=login') +def generator_view(request): + if request.method == 'POST': + form = GenerateForm(request.POST, request.FILES) + if form.is_valid(): + platform = form.cleaned_data['platform'] + version = form.cleaned_data['version'] + delayFix = form.cleaned_data['delayFix'] + server = form.cleaned_data['serverIP'] + key = form.cleaned_data['key'] + apiServer = form.cleaned_data['apiServer'] + urlLink = form.cleaned_data['urlLink'] + if not server: + server = 'rs-ny.rustdesk.com' #default rustdesk server + if not key: + key = 'OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=' #default rustdesk key + if not apiServer: + apiServer = server+":21114" + if not urlLink: + urlLink = "https://rustdesk.com" + direction = form.cleaned_data['direction'] + installation = form.cleaned_data['installation'] + settings = form.cleaned_data['settings'] + appname = form.cleaned_data['appname'] + filename = form.cleaned_data['exename'] + permPass = form.cleaned_data['permanentPassword'] + theme = form.cleaned_data['theme'] + themeDorO = form.cleaned_data['themeDorO'] + runasadmin = form.cleaned_data['runasadmin'] + passApproveMode = form.cleaned_data['passApproveMode'] + denyLan = form.cleaned_data['denyLan'] + enableDirectIP = form.cleaned_data['enableDirectIP'] + #ipWhitelist = form.cleaned_data['ipWhitelist'] + autoClose = form.cleaned_data['autoClose'] + permissionsDorO = form.cleaned_data['permissionsDorO'] + permissionsType = form.cleaned_data['permissionsType'] + enableKeyboard = form.cleaned_data['enableKeyboard'] + enableClipboard = form.cleaned_data['enableClipboard'] + enableFileTransfer = form.cleaned_data['enableFileTransfer'] + enableAudio = form.cleaned_data['enableAudio'] + enableTCP = form.cleaned_data['enableTCP'] + enableRemoteRestart = form.cleaned_data['enableRemoteRestart'] + enableRecording = form.cleaned_data['enableRecording'] + enableBlockingInput = form.cleaned_data['enableBlockingInput'] + enableRemoteModi = form.cleaned_data['enableRemoteModi'] + removeWallpaper = form.cleaned_data['removeWallpaper'] + defaultManual = form.cleaned_data['defaultManual'] + overrideManual = form.cleaned_data['overrideManual'] + + + filename = re.sub(r'[^\w\s-]', '_', filename).strip() + myuuid = str(uuid.uuid4()) + protocol = 'https' if request.is_secure() else 'http' + host = request.get_host() + full_url = f"{protocol}://{host}/api" + try: + iconfile = form.cleaned_data['iconfile'] + #iconbase64 = resize_and_encode_icon(iconfile) + iconlink = save_png(iconfile,myuuid,full_url) + except: + print("failed to get icon, using default") + #iconbase64 = b"iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAEiuAABIrgHwmhA7AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAEx9JREFUeJztnXmYHMV5h9+vZnZ0rHYRum8J4/AErQlgAQbMsRIWBEFCjK2AgwTisGILMBFCIMug1QLiPgIYE/QY2QQwiMVYjoSlODxEAgLEHMY8YuUEbEsOp3Z1X7vanf7yR8/MztEz0zPTPTO7M78/tnurvqn6uuqdr6q7a7pFVelrkpaPhhAMTEaYjJHDUWsEARkODANGAfWgINEPxLb7QNtBPkdoR7Ud0T8iphUTbtXp4z8pyQH5KOntAEhL2yCCnALW6aAnIDQAI+3MqFHkGJM73BkCO93JXnQnsAl4C8MGuoIv69mj2rw9ouKq1wEgzRiO2noSlp6DoRHleISgnQkJnRpLw0sI4v9X4H2E9Yj172zf+2udOflgYUdYXPUaAOTpzxoImJkIsxG+YCfG+Z7cecWDIN5+J8hqjNXCIW3rdMqULvdHWBqVNQDS8tlwNPCPKJcjOslOjGZGt2UHQTStHZGnMPxQG8d9mOk4S6myBEBWbj0aZR7ILISBPRlZOiMlr+QQgGAhvITqg0ybsEZjhZWHygoA+VnbaSBLEaY6dgb0Vgii+h2GO2gcv7JcQCgLAOSp7ZNBlyI6sycR+igEILoRdJFOnfgCJVZJAZCf7pxETfhmlIsQjHNH9VkIAF0H1iKdetjvKJFKAoC0EODA9msQvQUYmL2j8uwMJ/uygwAL0dvZMHGJNmFRZBUdAHlix5dQfQw4IbeO6tMQgOgybZx4I0VW0QCQ5dQQ2v4DhO8Dofw6qk9DEIZwg0497H8ookwxKpEV7WOo2fES0IQSAnrmwBrXEhq/lcR5cnJasm1KWq5lx9knl5NvvW7877EPIMFZFFm+AyA/2Xk6EngbOCVtA1chsO1V/4oiyzcABERW7FiI6osoo2IZVQicy7HtwxRZQT8KlWaCjNm5AiOzY+Oe0jPuqdjjXjQttpWe8TMhT0Djxs/ktGRbCi07g4/kWW/C8afxX/htAc2elzyPAPIQ/Ri7cyXCbBfjXjUS9Nh2IeEnKLI8BUB+1DaI/jvXoJwfS6xC4FxOcr2i12vjpM0UWZ6dBsry/aOh61fAMfmfCyfllfoU0Y2P+dab6P/d+rVx11MCeQKALN8zDA1vAJlc+AWRpLw+D4Hcp9PHLqBEKngIkBXtdVjWWlQmA4XMgBPTymU4cONj3vXKvaXsfCgQAGkhRGfoOZDjgHwnP3F5FQXBvTp97HWUWHkDIM0Y2nY/C5zpwQw4Lq8SINC79azSdz4UEgGG7l4CnOfJDDglr09DcK/+dWkmfE7KaxIoD++aDmYtaMCDGbBtXxETQ7lXzx5dFt/8qHIGQB7eORENvI0w1E4pZAacZN+XIUDu1XPKq/MhRwDkp/Rn7+7XQY6xE6I5ZQ/BbrB+j8gWkC2g7cBeAtJFdA2GyqGIDkUYA0xAtAEYkrFstxAY7tIZY26gDJXbvYDd+5qRuM7XyBbBt+vjONgnl0NKvZtRXYewAfRtvjX8Q00cwV1JWraNRbqPRbURkTOAoxGRnHzE3KUzRpVl50MOEUAe2H88Yr0GBEu/esapHPkjWE+CPKOzh25ydVA5Sp5vHw3hbwIXInoSEvEgnY/C7Xru6MV++AIgL245FmMuQmhArQ7EvInK4zpt3Meuy3ADgDQT4tC9b6EclbbzSgOBgq5B9T7mDNuQz7c8X8kv2o9Auq8C5gB1ST5uQ/VKPW/MSl/qbmkNMbTun1G+69A2BxDma+OER12V5QqA+/c2Y1jSk5BQYSkgUGAlAb3Zr2+7W8na7fV0dH0To18G3YOwkfrOn2vjpA5f6mtpDTGk7jmUv8n4BYFLdOqEf81aXjYA5L49R2DMRtCa1A6iFBC8glgLdM7QNzM63gclaz/sR03/51DOdREld9PV9Rd65uFbM5WZ/UKQBG5DqbEnenHp6S7yuL8gkrmceHs7bT8Wi/jzoY0V2fktrSHMgGdRzgXcXKSqpya0hCzKGAHkngNfwVivJ052nM6z8TsSvALM1ssHb8l2QH1Rsn5zfzprnkf0bDshPhMyRIIuAqZBTxv3QbqyM0eAgHUbINkvu+JjJNDlhAefUbGd39Ia4kBNC3B2HpfUa+i2bstYfroIIPftn4HyQgnX1nchXKFXDM46kemrkvWb+9MRWgV6lp0Qzchp0qyY8MnaOOkNpzrSRwAL+1cqpVlC1YnFhRXd+Ws/7Mf+fs+hkc6HXOZL8XmCFfxB2nqcIoDcc+AroG9EPh61jDOI33oeCQ6gOkO/M3h9Oqf7uqTlowHUml8C03Nq49h+ShtbqDlSzxj7v8l1OUcAteanHZsT0iI1eBcJurBkZkV3/ppPBzLQ/BvKdCC3Nnayt7cGY33Psb7kCCD3HRhPN39AtIZIWYlb3yKBAhfrd+ufdHK0EiRrPh0IuhqYljZK5h8J9hHS8XrKhB3xdaZGgG6uBGq8WZRBLpHg/oru/OXUoKwCmZYxSuYfCWrpNN9OrjcBAGnGoPT8QLFoEOgGttaX7R2zomjUpw8C010NlflCIFyaXG1iBAh1nAqMdbiq5CcEuyA8W5voTnauUiS/+PgIYG5O86V8IFD9S/mPj4+Jrzt5CLggzQUFByfwBgJlgc4b8n9UsgKBuajYfeE3BAG9IL7qGADSTBD4RoarSg5OUCgEL3FV3QoqXSpHRbaR/0ncegmBpRdI3HSxJwLUdE4FRqQ5jXAuuDAILLrNAk20qEypdvbs+w7BYfz6oxOiSSYu88wkQ58h4An9p9p3qQqEl121sVcQBJgR/bcHAGFaltOI7A66hyBMWG+lKlsHeRyho2gQWDRGdw2ANDMY5egUQ/8geF7n15ft83OLLZ05qo0wz9j/xGf4BsGJ9kWnaAQIHjwdCBTtFzzGuo+qkqQP5dTGhUEQop91EkQBsLTR9WmEWwfTQaDSqlfXO96arGTp+aPfAXm/aBCIPQxE5wDHpjVMKMQTCCr2cm9WKc/k3Mb5QmDpCdADQEPazvMaAhN4mqqcFQ635NXG+UHQYFss2zuScM1nsdyUu1BJ6bF9dbjD52CfWM4mvbZ2MlWllTz/+WZgYl5t7GSfXE58XqBzsKEr0BCjJWKbuPUwEgjrqCqzVP7T3oLvkaCr35EG4h/t4jMEYdlAVZkl1oa0nec1BCINBmRiiqFTwV5AYOQdqsqscMC+OloMCNDDDcoIR0OngguDYKteO6Cy7/q5UlsrYL9tzHcIdIQhdgPIwdCp4HwhsPT3VJVVOnPyQZQ/9CTEb72GQIYbkBEZDZ0KzgcCkc0pR1tVGsnHRXlmkTLcoDIiq6FTwTlDwBaqcifFfkex/xAMN6B1rmhxKjgnCGQ7VblVW0obgx8QDDEoxoUhBUMgupeq3EnFfraA/xCY3NehOdm7gSAs+6jKpbQjbRsnpEGhEBhUxI1hQoVO9tkgMFKU9xP1DUWaqggQGGwIshoWDEGY/lTlTsqgrG2ckpcfBAaNrMf3GwKRAVTlUjrIVRun5OUMgRqQbWk7z0sILB1BVe6UcHXWVwh2GFTbHQv2GgLDWKpyKZ2QUxun5LmGoN0A7amF+ACBMp6q3Ellgr2N/g8+QdBuEGlPnbSlGHoBQQNVZZU8/ekwkFF5tbGTfSYILN1qCOvWrOvHvIFgjDTvGUZVmaWBKWk7z3sI2g1iPkgxdCrYCwhqQsdSVRbJ8UD6zvMSAsyfDJa1ydEwXp5BoI0OpVcVL5VpPfvgKwQW7xtM8H1XtHgDwdeoKq3kic9rUU5OjcQ+QdBNq9Hb2AZsLQ4EMkVu3zucqpwlwekg/QCH4dhzCNp05qi26PX51gyGXkIQoLvmG1SVThcBqW0c2/cUglaI3nVQeSODoYMzBUAgXEhVKZKWHYegnJN28h3b9woC3oTYbSdrfVGWINn7p8qtnYdTVaIOWBcD9v2SYkCAvUTfBmBA8L+AriJBYFCuoqqYpIUAcE1qR+MXBGGk36sQAUCb2Av6joNh5gqdHHQHwWVyF3VUZWvf9vNROdz1tZjYfp4QiLyrfzd4J8Q/IcSSDWloyVyhk4PZIains6M6GYTow7mWAqltHEvDWwgsa320iB4AjFntWKFTwV5AoIHjqArG77gCmJy2jWNpeAcBsja61wPAAF5D+cixQqeCC4cg/pMVKfnZrkMRWercbr5B8Dk6cn30ozEAtAkLaHF/GlEgBEL1d4Kd4ftBRwJp2s0HCJSf60zC0Y8lLtRUszL1w/gAgbZRV/MMFSz58Y4ZqFySvd08hgBJeJdhIgD38BuI/ITLLwhEFORanc8BKlTy4+3jMPIT9+3mGQSfsGn4q/G+JACgimLJY/6uQ5Ol2hSq2OcESQshCLRg4fybTPAPAovHI0N9TKlr9UM8itLhCwSit2pT8OaUOitEAsKOnf8CeiKQz5enEAi6CQd+lOxTCgB6G22gT2U8jcgHAtE7dWnopuT6KkrLd92JcKmrbyt4C4HynF405KNkl9L8Wsc8mFBAihPkCkGzNocWOddVGZLluxYDCz150ko+EIg+5OSXIwB6N++hvJRQQIoTuIWgSW8JLnWqpxIkIPLIrrtRluU1bjvZ5w7BW3rhiNec/AtmcL0ZVfvlRQpIZEftunu2QuyxZQl5ApbepLcFK/ah0PIQ/ajZ/SjCJWnbLfo/9LSbaqItDvbJtmQoW0g778r87uDrdDVE31QddUbj9uO3ceXYTizR280taQvv45KHto8jGGwBTnTVbhL/4Yh9sq2TfbJtctnKqzpr2Knp/Mz8i11LFgHhlNAT2yc19Nj7iyu68x/ecx6B4DsoibP92D6p7ebbcGBlfBlXxggAIAusxxC5jLhjyEw0N+rtZlnGQvuo5JFdh2KZO4C5jt/g4keCVTpr6Ncz+Zz9N/tB04RiP9whWyQQrq/EzpdmQvLD3dcQNh+gzI2kOnzbI+kpafgRCboQSfvO4Jjv2SIAgCxgDugKJOK9E9GGhXqHuSdrYXlKbjnYgCWXYfQIIIRar6Os0Kb+f/arzqw+NRNi8L4LMXoT6BftxGhm1KpEkcDoLTpr2JKsx+AGAABZwCzQBxCGJFW4Hax5eldgZfpP5y9pJoR2PoDId5LqBTQMrAJ9iJv6v6yJ3xHfJA/sG4lYl6DyPWBs2s4rFQTQyu7tX9arv9hJFrkGAEAWcQjd/C1qNSAEEfMu+1mlD+PLA6BkIbXUdq0BGjM2ov3/FuBZxDxLd807yde8C/bl3j3DCJizUP4B4UzQYNqZd4qPCX76DYGFcIpePOR1V8eVCwDFlCykloFdLwCnu2rEhMaQbaDrgZdB36W74z1tstfAua7/no7DEJ0CHI9YU4EpgHF9+pXiYxb/nezzgUB5UC8dco2bY7Q/UoYARDr/Vyin5dSImTvjE+Aj0M8w8jkW3QR0N4ogMhi0FiPDUGsCMAmJLNFOd53Dfb3u/XeyzwUC5T26O07SuaP341JlB4A0M5Cu7jUIUz17MUIujeimM/Kt118I9iDWCTpnaE7PZC6rR7cldD6kOdUBcDg1ynpBBIe8DOU41evm3ke8ivH0NY38F5Y5uXY+lBEA0sxADnavAaZmP9+FsoagUP8z1evs/x16xeDnyUNlAYA0M4jO8DqQqZ41YqVAYPEC9Yfmvc6i5ADIQmrpCK8GTvW8Efs8BPIG/TsviF/lm6tKOgmUhdQSDEfO80k/sUo+1UmxTWNfLhPDQv13tt9IwJyul9cX9BT2kgEgC6kloGtAG4vSiH0Lgj9BzVd17sBPKVAlGQKkmUGY8LrYM4OKEU77znCwGZjuRedDCQAQQdinT6JyClDcRuz9EGykq+urOveQnncKFaiiDwFyPeeCri5pOO2dw8F/Y8k5emXdNjxU8YcAy5pV8m9Sb4sEsIbAvmledz6UZA4gRwKlD6e9AwIFvYut9V/P5fp+LsqwKtg3daHYbaeQ12pj16tmsf8k2yeXg0O9CWWnqddf/3cizNF5h/yykMbOphIMAfo2UD4Tq3KMBOi7qHWcXlnna+dDKQBQ8yjRh0NUIUiuw0LlAbrqT9arvZvpZ1JJLgTJtSxDdHGZzK7L5exgI8b6tl5d3/PMxiKoNPcC7udGVK5HsdesVXYk6ASa2DloSrE7H0oUAWKVX8dE1FqGyLdwWm4V2yeXb1JviQSK6CosXawL6kr2Yu2yWBEk19KA0TuBcyoDAl5Dwot0ft0rlFhlAUBUch1ngd5AdEVQX4NA+A1Gm3R+7TrKRGUFQFSygKMJWPNQuRihfy+HoAt0FaLL9braFx0PuIQqSwCikvmMpsaaBzILdJKdGM2MbssWgo8RXUE3j+hib+7c+aGyBiBesogGwtZsDBcDo+3EaGaZQKC0Y1iLWC10DFyrTZG3spaxeg0AUcnfE+Cw7tNQcyZGp4JMAYIlgqAb0d+isoGgrqaj/6te/yLJb/U6AJIlN1CHhE9DZSpGjwUagJE+QdCG8D6qbxCQlwn2e1WvZ4/Xx1RM9XoAnCSLGQrdX0LNkYh1GCIjEB2GMhzRUYjU9xgnQLAdQztoO8o2hK0gH2BkE8Fgq34fz2/Hllr/D1DoAB9bI40ZAAAAAElFTkSuQmCC" + iconlink = "false" + try: + logofile = form.cleaned_data['logofile'] + #logobase64 = resize_and_encode_icon(logofile) + logolink = save_png(logofile,myuuid,full_url) + except: + print("failed to get logo") + #logobase64 = b"" + logolink = "false" + + ###create the custom.txt json here and send in as inputs below + decodedCustom = {} + if direction != "Both": + decodedCustom['conn-type'] = direction + if installation == "installationN": + decodedCustom['disable-installation'] = 'Y' + if settings == "settingsN": + decodedCustom['disable-settings'] = 'Y' + if appname.upper != "rustdesk".upper and appname != "": + decodedCustom['app-name'] = appname + decodedCustom['override-settings'] = {} + decodedCustom['default-settings'] = {} + if permPass != "": + decodedCustom['password'] = permPass + if theme != "system": + if themeDorO == "default": + decodedCustom['default-settings']['theme'] = theme + elif themeDorO == "override": + decodedCustom['override-settings']['theme'] = theme + decodedCustom['approve-mode'] = passApproveMode + decodedCustom['enable-lan-discovery'] = 'N' if denyLan else 'Y' + decodedCustom['direct-server'] = 'Y' if enableDirectIP else 'N' + decodedCustom['allow-auto-disconnect'] = 'Y' if autoClose else 'N' + decodedCustom['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N' + if permissionsDorO == "default": + decodedCustom['default-settings']['access-mode'] = permissionsType + decodedCustom['default-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N' + decodedCustom['default-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N' + decodedCustom['default-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N' + decodedCustom['default-settings']['enable-audio'] = 'Y' if enableAudio else 'N' + decodedCustom['default-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N' + decodedCustom['default-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N' + decodedCustom['default-settings']['enable-record-session'] = 'Y' if enableRecording else 'N' + decodedCustom['default-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N' + decodedCustom['default-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N' + else: + decodedCustom['override-settings']['access-mode'] = permissionsType + decodedCustom['override-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N' + decodedCustom['override-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N' + decodedCustom['override-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N' + decodedCustom['override-settings']['enable-audio'] = 'Y' if enableAudio else 'N' + decodedCustom['override-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N' + decodedCustom['override-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N' + decodedCustom['override-settings']['enable-record-session'] = 'Y' if enableRecording else 'N' + decodedCustom['override-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N' + decodedCustom['override-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N' + + for line in defaultManual.splitlines(): + k, value = line.split('=') + decodedCustom['default-settings'][k.strip()] = value.strip() + + for line in overrideManual.splitlines(): + k, value = line.split('=') + decodedCustom['override-settings'][k.strip()] = value.strip() + + decodedCustomJson = json.dumps(decodedCustom) + + string_bytes = decodedCustomJson.encode("ascii") + base64_bytes = base64.b64encode(string_bytes) + encodedCustom = base64_bytes.decode("ascii") + + #github limits inputs to 10, so lump extras into one with json + extras = {} + extras['runasadmin'] = runasadmin + extras['urlLink'] = urlLink + extras['delayFix'] = 'true' if delayFix else 'false' + extras['version'] = version + extras['rdgen'] = 'false' + extra_input = json.dumps(extras) + + if _settings.GHUSER == '': + ####run the github actions through rdgen.crayoneater.org + url = 'https://rdgen.crayoneater.org/startgh' + data = { + "ref":"master", + "inputs":{ + "server":server, + "key":key, + "apiServer":apiServer, + "custom":encodedCustom, + "uuid":myuuid, + "iconlink":iconlink, + "logolink":logolink, + "appname":appname, + "extras":extra_input, + "filename":filename, + "platform":platform + } + } + response = requests.post(url, json=data) + else: + ####run the github actions through user's own github fork of rdgen + url = 'https://api.github.com/repos/'+_settings.GHUSER+'/rdgen/actions/workflows/generator-'+platform+'.yml/dispatches' + data = { + "ref":"master", + "inputs":{ + "server":server, + "key":key, + "apiServer":apiServer, + "custom":encodedCustom, + "uuid":myuuid, + "iconlink":iconlink, + "logolink":logolink, + "appname":appname, + "extras":extra_input, + "filename":filename + } + } + headers = { + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'Authorization': 'Bearer '+_settings.GHBEARER, + 'X-GitHub-Api-Version': '2022-11-28' + } + response = requests.post(url, json=data, headers=headers) + + print(response) + if response.status_code == 204: + create_github_run(myuuid) + return render(request, 'waiting.html', {'filename':filename, 'uuid':myuuid, 'status':"Starting generator...please wait", 'phone_or_desktop': is_mobile(request), 'platform':platform}) + else: + return JsonResponse({"error": "Something went wrong"}) + else: + form = GenerateForm() + return render(request, 'generator.html', {'form': form, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def check_for_file(request): + filename = request.GET['filename'] + uuid = request.GET['uuid'] + platform = request.GET['platform'] + gh_run = GithubRun.objects.filter(Q(uuid=uuid)).first() + status = gh_run.status + + if status == "Success": + #return render(request, 'generated.html', {'filename': filename, 'uuid':uuid, 'phone_or_desktop': is_mobile(request)}) + return HttpResponseRedirect('/api/clients') + else: + return render(request, 'waiting.html', {'filename':filename, 'uuid':uuid, 'status':status, 'platform':platform, 'phone_or_desktop': is_mobile(request)}) + +@login_required(login_url='/api/user_action?action=login') +def download_client(request): + filename = request.GET['filename'] + uuid = request.GET['uuid'] + #filename = filename+".exe" + file_path = os.path.join('exe',uuid,filename) + with open(file_path, 'rb') as file: + response = HttpResponse(file, headers={ + 'Content-Type': 'application/vnd.microsoft.portable-executable', + 'Content-Disposition': f'attachment; filename="{filename}"' + }) + + return response + +def save_png(file, uuid, domain): + file_save_path = "png/%s/%s" % (uuid, quote(file.name)) + Path("png/%s" % uuid).mkdir(parents=True, exist_ok=True) + with open(file_save_path, "wb+") as f: + for chunk in file.chunks(): + f.write(chunk) + imageJson = {} + imageJson['url'] = domain + imageJson['uuid'] = uuid + imageJson['file'] = quote(file.name) + #return "%s/%s" % (domain, file_save_path) + return json.dumps(imageJson) + +def get_png(request): + filename = request.GET['filename'] + uuid = request.GET['uuid'] + #filename = filename+".exe" + file_path = os.path.join('png',uuid,filename) + with open(file_path, 'rb') as file: + response = HttpResponse(file, headers={ + 'Content-Type': 'application/vnd.microsoft.portable-executable', + 'Content-Disposition': f'attachment; filename="{filename}"' + }) + + return response + + +def create_github_run(myuuid): + new_github_run = GithubRun( + uuid=myuuid, + status="Starting generator...please wait" + ) + new_github_run.save() + +def update_github_run(request): + data = json.loads(request.body) + myuuid = data.get('uuid') + mystatus = data.get('status') + GithubRun.objects.filter(Q(uuid=myuuid)).update(status=mystatus) + return HttpResponse('') + +def save_custom_client(request): + file = request.FILES['file'] + file_save_path = "clients/custom/%s" % file.name + pathlib.Path("clients/custom").mkdir(parents=True, exist_ok=True) + with open(file_save_path, "wb+") as f: + for chunk in file.chunks(): + f.write(chunk) + + return HttpResponse("File saved successfully!") + +def resize_and_encode_icon(imagefile): + maxWidth = 200 + try: + with io.BytesIO() as image_buffer: + for chunk in imagefile.chunks(): + image_buffer.write(chunk) + image_buffer.seek(0) + + img = Image.open(image_buffer) + imgcopy = img.copy() + except (IOError, OSError): + raise ValueError("Uploaded file is not a valid image format.") + + # Check if resizing is necessary + if img.size[0] <= maxWidth: + with io.BytesIO() as image_buffer: + imgcopy.save(image_buffer, format=imagefile.content_type.split('/')[1]) + image_buffer.seek(0) + return_image = ContentFile(image_buffer.read(), name=imagefile.name) + return base64.b64encode(return_image.read()) + + # Calculate resized height based on aspect ratio + wpercent = (maxWidth / float(img.size[0])) + hsize = int((float(img.size[1]) * float(wpercent))) + + # Resize the image while maintaining aspect ratio using LANCZOS resampling + imgcopy = imgcopy.resize((maxWidth, hsize), Image.Resampling.LANCZOS) + + with io.BytesIO() as resized_image_buffer: + imgcopy.save(resized_image_buffer, format=imagefile.content_type.split('/')[1]) + resized_image_buffer.seek(0) + + resized_imagefile = ContentFile(resized_image_buffer.read(), name=imagefile.name) + + # Return the Base64 encoded representation of the resized image + resized64 = base64.b64encode(resized_imagefile.read()) + #print(resized64) + return resized64 + + +def is_mobile(request): + user_agent = request.META['HTTP_USER_AGENT'] + if 'Mobile' in user_agent or 'Android' in user_agent or 'iPhone' in user_agent: + return 'base_phone.html' + else: + return 'base.html' \ No newline at end of file diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..92ca442 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,18 @@ +version: "3.8" +services: + rustdesk-api-server: + container_name: rustdesk-api-server + build: + context: . + environment: + - HOST=0.0.0.0 + - TZ=Asia/Shanghai + - CSRF_TRUSTED_ORIGINS=http://yourdomain.com:21114 + volumes: + - /yourpath/db:/rustdesk-api-server/db + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + network_mode: bridge + ports: + - "21114:21114" + restart: unless-stopped diff --git a/images/admin_devices.png b/images/admin_devices.png new file mode 100644 index 0000000..687cb03 Binary files /dev/null and b/images/admin_devices.png differ diff --git a/images/admin_main.png b/images/admin_main.png new file mode 100644 index 0000000..9dbf5e0 Binary files /dev/null and b/images/admin_main.png differ diff --git a/images/admin_peers.png b/images/admin_peers.png new file mode 100644 index 0000000..3ae2406 Binary files /dev/null and b/images/admin_peers.png differ diff --git a/images/admin_tags.png b/images/admin_tags.png new file mode 100644 index 0000000..bcac5b5 Binary files /dev/null and b/images/admin_tags.png differ diff --git a/images/admin_users.png b/images/admin_users.png new file mode 100644 index 0000000..f10c881 Binary files /dev/null and b/images/admin_users.png differ diff --git a/images/clients.png b/images/clients.png new file mode 100644 index 0000000..68caa64 Binary files /dev/null and b/images/clients.png differ diff --git a/images/connection_log.png b/images/connection_log.png new file mode 100644 index 0000000..46c6847 Binary files /dev/null and b/images/connection_log.png differ diff --git a/images/file_log.png b/images/file_log.png new file mode 100644 index 0000000..6ceafa8 Binary files /dev/null and b/images/file_log.png differ diff --git a/images/front_login.png b/images/front_login.png new file mode 100644 index 0000000..c1a48c4 Binary files /dev/null and b/images/front_login.png differ diff --git a/images/front_main.png b/images/front_main.png new file mode 100644 index 0000000..4656627 Binary files /dev/null and b/images/front_main.png differ diff --git a/images/front_reg.png b/images/front_reg.png new file mode 100644 index 0000000..2badf26 Binary files /dev/null and b/images/front_reg.png differ diff --git a/images/rust_books.png b/images/rust_books.png new file mode 100644 index 0000000..5ba2708 Binary files /dev/null and b/images/rust_books.png differ diff --git a/images/share.png b/images/share.png new file mode 100644 index 0000000..419678e Binary files /dev/null and b/images/share.png differ diff --git a/images/user_devices.png b/images/user_devices.png new file mode 100644 index 0000000..b7d2e81 Binary files /dev/null and b/images/user_devices.png differ diff --git a/images/webui.png b/images/webui.png new file mode 100644 index 0000000..913627f Binary files /dev/null and b/images/webui.png differ diff --git a/images/windows_run.png b/images/windows_run.png new file mode 100644 index 0000000..34364a9 Binary files /dev/null and b/images/windows_run.png differ diff --git a/locale/en/LC_MESSAGES/django.mo b/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..eb6af4d Binary files /dev/null and b/locale/en/LC_MESSAGES/django.mo differ diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..52e86f4 --- /dev/null +++ b/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,592 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-05-14 10:44+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: .\api\admin_user.py:14 .\api\front_locale.py:28 +msgid "密码" +msgstr "Password" + +#: .\api\admin_user.py:15 +msgid "再次输入密码" +msgstr "Re-enter password" + +#: .\api\admin_user.py:26 +msgid "密码校验失败,两次密码不一致。" +msgstr "Password verification failed, passwords do not match." + +#: .\api\admin_user.py:44 +msgid "密码Hash值" +msgstr "Password Hash Value" + +#: .\api\admin_user.py:75 +msgid "基本信息" +msgstr "Basic Information" + +#: .\api\admin_user.py:100 +msgid "RustDesk自建Web" +msgstr "RustDesk Self-Hosted Web" + +#: .\api\admin_user.py:101 +msgid "未定义" +msgstr "Undefined" + +#: .\api\front_locale.py:4 .\api\templates\base.html:42 +msgid "管理后台" +msgstr "Admin Panel" + +#: .\api\front_locale.py:5 +msgid "ID列表" +msgstr "ID List" + +#: .\api\front_locale.py:6 +msgid "分享机器" +msgstr "Share Machine" + +#: .\api\front_locale.py:7 +msgid "这么简易的东西,忘记密码这功能就没必要了吧。" +msgstr "" +"For such a simple thing, the forgot password feature is unnecessary, right?" + +#: .\api\front_locale.py:8 +msgid "立即注册" +msgstr "Register Now" + +#: .\api\front_locale.py:9 +msgid "创建时间" +msgstr "Creation Time" + +#: .\api\front_locale.py:10 +msgid "注册成功,请前往登录页登录。" +msgstr "Registration successful, please go to the login page to login." + +#: .\api\front_locale.py:11 +msgid "注册日期" +msgstr "Registration Date" + +#: .\api\front_locale.py:12 +msgid "" +"2、所分享的机器,被分享人享有相同的权限,如果机器设置了保存密码,被分享人也可" +"以直接连接。" +msgstr "" +"2. The shared machine grants the same permissions to the recipient. If the " +"machine is set to save password, the recipient can also connect directly." + +#: .\api\front_locale.py:13 +msgid "导出xlsx" +msgstr "Export as xlsx" + +#: .\api\front_locale.py:14 +msgid "生成分享链接" +msgstr "Generate Share Link" + +#: .\api\front_locale.py:15 +msgid "请输入8~20位密码。可以包含字母、数字和特殊字符。" +msgstr "" +"Please enter a password of 8~20 characters. It can contain letters, numbers, " +"and special characters." + +#: .\api\front_locale.py:16 +msgid "尾页" +msgstr "Last Page" + +#: .\api\front_locale.py:17 +msgid "请确认密码" +msgstr "Please confirm password" + +#: .\api\front_locale.py:18 +msgid "注册" +msgstr "Register" + +#: .\api\front_locale.py:19 .\api\models_work.py:73 +msgid "内存" +msgstr "Memory" + +#: .\api\front_locale.py:20 .\api\templates\base.html:31 +msgid "首页" +msgstr "Home" + +#: .\api\front_locale.py:21 .\api\templates\base.html:37 +msgid "网页控制" +msgstr "Web Control" + +#: .\api\front_locale.py:22 +msgid "注册时间" +msgstr "Registration Time" + +#: .\api\front_locale.py:23 +msgid "链接地址" +msgstr "Link Address" + +#: .\api\front_locale.py:24 +msgid "请输入密码" +msgstr "Please enter password" + +#: .\api\front_locale.py:25 .\api\models_work.py:50 .\api\models_work.py:76 +msgid "系统用户名" +msgstr "System Username" + +#: .\api\front_locale.py:26 +msgid "状态" +msgstr "Status" + +#: .\api\front_locale.py:27 +msgid "已有账号?立即登录" +msgstr "Already have an account? Login now" + +#: .\api\front_locale.py:29 .\api\models_work.py:52 +msgid "别名" +msgstr "Alias" + +#: .\api\front_locale.py:30 +msgid "上一页" +msgstr "Previous Page" + +#: .\api\front_locale.py:31 +msgid "更新时间" +msgstr "Update Time" + +#: .\api\front_locale.py:32 +msgid "综合屏" +msgstr "Comprehensive Screen" + +#: .\api\front_locale.py:33 .\api\models_work.py:53 +msgid "平台" +msgstr "Platform" + +#: .\api\front_locale.py:34 +msgid "全部用户" +msgstr "All Users" + +#: .\api\front_locale.py:35 +msgid "注册页" +msgstr "Registration Page" + +#: .\api\front_locale.py:36 +msgid "分享机器给其他用户" +msgstr "Share machine with other users" + +#: .\api\front_locale.py:37 .\api\templates\base.html:33 +msgid "所有设备" +msgstr "All Devices" + +#: .\api\front_locale.py:38 +msgid "连接密码" +msgstr "Connection Password" + +#: .\api\front_locale.py:39 +msgid "设备统计" +msgstr "Device Statistics" + +#: .\api\front_locale.py:40 +msgid "所属用户" +msgstr "Belongs to User" + +#: .\api\front_locale.py:41 .\api\templates\base.html:36 +msgid "分享" +msgstr "Share" + +#: .\api\front_locale.py:42 +msgid "请输入用户名" +msgstr "Please enter username" + +#: .\api\front_locale.py:43 +msgid "1、链接有效期为15分钟,切勿随意分享给他人。" +msgstr "" +"1. The link is valid for 15 minutes. Do not share it with others casually." + +#: .\api\front_locale.py:44 +msgid "CPU" +msgstr "CPU" + +#: .\api\front_locale.py:45 .\api\models_work.py:49 .\api\models_work.py:70 +msgid "客户端ID" +msgstr "Client ID" + +#: .\api\front_locale.py:46 +msgid "下一页" +msgstr "Next Page" + +#: .\api\front_locale.py:47 +msgid "登录" +msgstr "Login" + +#: .\api\front_locale.py:48 .\api\templates\base.html:45 +msgid "退出" +msgstr "Logout" + +#: .\api\front_locale.py:49 +msgid "请将要分享的机器调整到右侧" +msgstr "Please adjust the machines to be shared to the right" + +#: .\api\front_locale.py:50 +msgid "成功!如需分享,请复制以下链接给其他人:
" +msgstr "" +"Success! If you need to share, please copy the following link to others:
" + +#: .\api\front_locale.py:51 +msgid "忘记密码?" +msgstr "Forgot Password?" + +#: .\api\front_locale.py:52 +msgid "计算机名" +msgstr "Computer Name" + +#: .\api\front_locale.py:53 +msgid "两次输入密码不一致!" +msgstr "Passwords do not match!" + +#: .\api\front_locale.py:54 +msgid "页码" +msgstr "Page Number" + +#: .\api\front_locale.py:55 +msgid "版本" +msgstr "Version" + +#: .\api\front_locale.py:56 .\api\models_user.py:32 .\api\models_work.py:9 +msgid "用户名" +msgstr "Username" + +#: .\api\front_locale.py:57 +msgid "" +"3、为保障安全,链接有效期为15分钟、链接仅有效1次。链接一旦被(非分享人的登录" +"用户)访问,分享生效,后续访问链接失效。" +msgstr "" +"3. For security reasons, the link is valid for 15 minutes and only valid " +"once. Once the link is accessed by a user (other than the sharing person), " +"the sharing becomes effective, and subsequent access to the link will be " +"invalid." + +#: .\api\front_locale.py:58 +msgid "系统" +msgstr "System" + +#: .\api\front_locale.py:59 +msgid "我的机器" +msgstr "My Machine" + +#: .\api\front_locale.py:60 +#, fuzzy +#| msgid "基本信息" +msgid "信息" +msgstr "Basic Information" + +#: .\api\front_locale.py:61 +msgid "远程ID" +msgstr "Remote ID" + +#: .\api\front_locale.py:62 +msgid "远程别名" +msgstr "Remote Alias" + +#: .\api\front_locale.py:63 .\api\models_work.py:11 .\api\models_work.py:48 +#: .\api\models_work.py:126 +msgid "用户ID" +msgstr "User ID" + +#: .\api\front_locale.py:64 +msgid "用户别名" +msgstr "User Alias" + +#: .\api\front_locale.py:65 +msgid "用户IP" +msgstr "User IP" + +#: .\api\front_locale.py:66 +msgid "文件大小" +msgstr "Filesize" + +#: .\api\front_locale.py:67 +msgid "发送/接受" +msgstr "Sent/Received" + +#: .\api\front_locale.py:68 +msgid "记录于" +msgstr "Logged At" + +#: .\api\front_locale.py:69 +msgid "连接开始时间" +msgstr "Connection Start Time\t" + +#: .\api\front_locale.py:70 +msgid "连接结束时间" +msgstr "Connection End Time" + +#: .\api\front_locale.py:71 +msgid "时长" +msgstr "Duration" + +#: .\api\front_locale.py:72 .\api\templates\base.html:40 +msgid "连接日志" +msgstr "Connection Log" + +#: .\api\front_locale.py:73 .\api\templates\base.html:41 +msgid "文件传输日志" +msgstr "File Transfer Log" + +#: .\api\front_locale.py:74 +msgid "页码 #" +msgstr "Page #" + +#: .\api\front_locale.py:75 +msgid "下一页 #" +msgstr "Next #" + +#: .\api\front_locale.py:76 +msgid "上一页 #" +msgstr "Previous #" + +#: .\api\front_locale.py:77 +#, fuzzy +#| msgid "上一页" +msgid "第一页" +msgstr "First" + +#: .\api\front_locale.py:78 +#, fuzzy +#| msgid "上一页" +msgid "上页" +msgstr "Previous Page" + +#: .\api\models_user.py:40 +msgid "登录信息:" +msgstr "Login Information:" + +#: .\api\models_user.py:42 +msgid "是否激活" +msgstr "Is Active" + +#: .\api\models_user.py:43 +msgid "是否管理员" +msgstr "Is Administrator" + +#: .\api\models_user.py:82 +msgid "用户" +msgstr "User" + +#: .\api\models_user.py:83 +msgid "用户列表" +msgstr "User List" + +#: .\api\models_work.py:10 +msgid "RustDesk ID" +msgstr "RustDesk ID" + +#: .\api\models_work.py:12 +msgid "uuid" +msgstr "UUID" + +#: .\api\models_work.py:13 +msgid "access_token" +msgstr "Access Token" + +#: .\api\models_work.py:14 +msgid "登录时间" +msgstr "Login Time" + +#: .\api\models_work.py:19 +msgid "Token列表" +msgstr "Token List" + +#: .\api\models_work.py:30 +msgid "所属用户ID" +msgstr "Belongs to User ID" + +#: .\api\models_work.py:31 +msgid "标签名称" +msgstr "Tag Name" + +#: .\api\models_work.py:32 +msgid "标签颜色" +msgstr "Tag Color" + +#: .\api\models_work.py:37 +msgid "Tags列表" +msgstr "Tags List" + +#: .\api\models_work.py:51 +msgid "操作系统名" +msgstr "Operating System Name" + +#: .\api\models_work.py:54 +msgid "标签" +msgstr "Tag" + +#: .\api\models_work.py:55 +msgid "设备链接密码" +msgstr "Device Connection Password" + +#: .\api\models_work.py:60 +msgid "Peers列表" +msgstr "Peers List" + +#: .\api\models_work.py:72 +msgid "主机名" +msgstr "Hostname" + +#: .\api\models_work.py:74 +msgid "操作系统" +msgstr "Operating System" + +#: .\api\models_work.py:77 +msgid "客户端版本" +msgstr "Client Version" + +#: .\api\models_work.py:78 +msgid "设备注册时间" +msgstr "Device Registration Time" + +#: .\api\models_work.py:83 +msgid "设备" +msgstr "Device" + +#: .\api\models_work.py:84 +msgid "设备列表" +msgstr "Device List" + +#: .\api\models_work.py:127 +msgid "链接Key" +msgstr "Link Key" + +#: .\api\models_work.py:128 +msgid "机器ID列表" +msgstr "Machine ID List" + +#: .\api\models_work.py:129 +msgid "是否使用" +msgstr "Is Used" + +#: .\api\models_work.py:130 +msgid "是否过期" +msgstr "Is Expired" + +#: .\api\models_work.py:131 +msgid "生成时间" +msgstr "Generation Time" + +#: .\api\models_work.py:137 +msgid "分享链接" +msgstr "Share Link" + +#: .\api\models_work.py:138 +msgid "链接列表" +msgstr "Link List" + +#: .\api\views_api.py:20 +msgid "请求方式错误!请使用POST方式。" +msgstr "Request method error! Please use the POST method." + +#: .\api\views_api.py:34 +msgid "帐号或密码错误!请重试,多次重试后将被锁定IP!" +msgstr "" +"Account or password error! Please retry. After multiple retries, the IP will " +"be locked!" + +#: .\api\views_api.py:72 +msgid "请求方式错误!" +msgstr "Request method error!" + +#: .\api\views_api.py:80 +msgid "异常请求!" +msgstr "Abnormal request!" + +#: .\api\views_api.py:93 .\api\views_api.py:213 +msgid "错误的提交方式!" +msgstr "Incorrect submission method!" + +#: .\api\views_api.py:121 +msgid "拉取列表错误!" +msgstr "Error fetching list!" + +#: .\api\views_api.py:200 +msgid "更新地址簿有误" +msgstr "Error updating address book" + +#: .\api\views_api.py:247 .\api\views_front.py:207 .\api\views_front.py:226 +msgid "在线" +msgstr "Online" + +#: .\api\views_api.py:308 +msgid "好的" +msgstr "Okay" + +#: .\api\views_front.py:50 +msgid "model_to_dict接收的参数必须是模型对象" +msgstr "The parameter received by model_to_dict must be a model object" + +#: .\api\views_front.py:55 +#, python-brace-format +msgid "model_to_dict,要替换成{replace_field}字段已经存在了" +msgstr "" +"model_to_dict, the field to be replaced with {replace_field} already exists" + +#: .\api\views_front.py:60 +#, python-brace-format +msgid "model_to_dict,要新增默认值,但字段{default_key}已经存在了" +msgstr "" +"model_to_dict, to add default values, but the field {default_key} already " +"exists" + +#: .\api\views_front.py:134 +msgid "出了点问题,未获取用户名或密码。" +msgstr "There was a problem, username or password not obtained." + +#: .\api\views_front.py:141 +msgid "帐号或密码错误!" +msgstr "Account or password error!" + +#: .\api\views_front.py:153 +msgid "当前未开放注册,请联系管理员!" +msgstr "Registration is currently not open, please contact the administrator!" + +#: .\api\views_front.py:160 +msgid "用户名不得小于3位" +msgstr "Username must be at least 3 characters" + +#: .\api\views_front.py:165 +msgid "密码长度不符合要求, 应在8~20位。" +msgstr "" +"Password length does not meet requirements, should be between 8~20 " +"characters." + +#: .\api\views_front.py:171 +msgid "用户名已存在。" +msgstr "Username already exists." + +#: .\api\views_front.py:207 .\api\views_front.py:226 +msgid "离线" +msgstr "Offline" + +#: .\api\views_front.py:210 +msgid "是" +msgstr "Yes" + +#: .\api\views_front.py:210 +msgid "否" +msgstr "No" + +#: .\api\views_front.py:252 +msgid "设备信息表" +msgstr "Device Information Table" + +#: .\api\views_front.py:351 +msgid "数据为空。" +msgstr "Data is empty." + +#: .\api\views_front.py:373 .\api\views_front.py:378 .\api\views_front.py:409 +#: .\api\views_front.py:414 +msgid "UNKNOWN" +msgstr "" + +#~ msgid "未知" +#~ msgstr "UNKNOWN" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d03d5ee --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rustdesk_server_api.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bff98d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +django +xlwt +python-dateutil +psutil +requests +pillow \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..3ce04d5 --- /dev/null +++ b/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cd /rustdesk-api-server; + +if [ ! -e "./db/db.sqlite3" ]; then + cp "./db_bak/db.sqlite3" "./db/db.sqlite3" + echo "首次运行,初始化数据库" +fi + +python manage.py makemigrations +python manage.py migrate +python manage.py runserver $HOST:21114; diff --git a/rustdesk_server_api/__init__.py b/rustdesk_server_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rustdesk_server_api/asgi.py b/rustdesk_server_api/asgi.py new file mode 100644 index 0000000..9695e30 --- /dev/null +++ b/rustdesk_server_api/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for rustdesk_server_api project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rustdesk_server_api.settings') + +application = get_asgi_application() diff --git a/rustdesk_server_api/settings.py b/rustdesk_server_api/settings.py new file mode 100644 index 0000000..6b28518 --- /dev/null +++ b/rustdesk_server_api/settings.py @@ -0,0 +1,177 @@ +""" +Django settings for rustdesk_server_api project. + +Generated by 'django-admin startproject' using Django 3.1.7. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +if "CSRF_TRUSTED_ORIGINS" in os.environ: + CSRF_TRUSTED_ORIGINS = [os.environ["CSRF_TRUSTED_ORIGINS"]] +else: + CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1:21114"] + SECURE_CROSS_ORIGIN_OPENER_POLICY = 'None' +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get("SECRET_KEY", 'j%7yjvygpih=6b%qf!q%&ixpn+27dngzdu-i3xh-^3xgy3^nnc') +# ID服务器IP或域名,一般与中继服务器,用于web client +ID_SERVER = os.environ.get("ID_SERVER", '') +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get("DEBUG", False) +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +ALLOWED_HOSTS = ["*"] +AUTH_USER_MODEL = 'api.UserProfile' # AppName.自定义user + +ALLOW_REGISTRATION = os.environ.get("ALLOW_REGISTRATION", "True") # 是否允许注册, True为允许,False为不允许 +ALLOW_REGISTRATION = True if ALLOW_REGISTRATION.lower() == 'true' else False + +GHUSER = os.environ.get("GHUSER", '') +GHBEARER = os.environ.get("GHBEARER", '') + + +# ==========数据库配置 开始===================== +DATABASE_TYPE = os.environ.get("DATABASE_TYPE", 'SQLITE') +MYSQL_DBNAME = os.environ.get("MYSQL_DBNAME", '-') +MYSQL_HOST = os.environ.get("MYSQL_HOST", '127.0.0.1') +MYSQL_USER = os.environ.get("MYSQL_USER", '-') +MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", '-') +MYSQL_PORT = os.environ.get("MYSQL_PORT", '3306') +# ==========数据库配置 结束===================== + +LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", 'zh-hans') +# #LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", 'en') + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'api', + 'webui', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', # 取消post的验证。 + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'rustdesk_server_api.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'api.util.settings', + ], + }, + }, +] + +WSGI_APPLICATION = 'rustdesk_server_api.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db/db.sqlite3', + }, +} +if DATABASE_TYPE == 'MYSQL' and MYSQL_DBNAME != '-' and MYSQL_USER != '-' and MYSQL_PASSWORD != '-': + # 简单通过数据库名、账密信息过滤下,防止用户未配置mysql却使用mysql + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': MYSQL_DBNAME, # 数据库名 + 'HOST': MYSQL_HOST, # 数据库服务器IP + 'USER': MYSQL_USER, # 数据库用户名 + 'PASSWORD': MYSQL_PASSWORD, # 数据库密码 + 'PORT': MYSQL_PORT, # 端口 + 'OPTIONS': {'charset': 'utf8'}, + } + } + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = 'en' + +TIME_ZONE = 'America/Chicago' + +USE_I18N = True + +USE_L10N = True + +# USE_TZ = True +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + +STATIC_URL = 'static/' + +if DEBUG: + STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] + +else: + STATIC_ROOT = os.path.join(BASE_DIR, 'static') # 新增 + +LANGUAGES = ( + ('zh-hans', '中文简体'), + ('en', 'English'), + +) + +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale'), +) diff --git a/rustdesk_server_api/urls.py b/rustdesk_server_api/urls.py new file mode 100644 index 0000000..3e6bbc1 --- /dev/null +++ b/rustdesk_server_api/urls.py @@ -0,0 +1,43 @@ +"""rustdesk_server_api URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +import django +from django.contrib import admin +from django.urls import path + +from api.views import index +if django.__version__.split('.')[0]>='4': + from django.urls import re_path as url + from django.conf.urls import include +else: + from django.conf.urls import url, include +from django.views import static ##新增 +from django.conf import settings + + +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), + path('admin/', admin.site.urls), + url(r'^$', index), + url(r'^api/', include('api.urls')), + url(r'^webui/', include('webui.urls')), + url(r'^static/(?P.*)$', static.serve, {'document_root': settings.STATIC_ROOT}, name='static'), + url(r'^canvaskit@0.33.0/(?P.*)$', static.serve, {'document_root': 'static/web_client/canvaskit@0.33.0'},name='web_client'), + +] + +from django.conf.urls import static as sc +if not settings.DEBUG: + urlpatterns += sc.static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file diff --git a/rustdesk_server_api/wsgi.py b/rustdesk_server_api/wsgi.py new file mode 100644 index 0000000..77e80e8 --- /dev/null +++ b/rustdesk_server_api/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for rustdesk_server_api project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rustdesk_server_api.settings') + +application = get_wsgi_application() diff --git a/static/admin/css/autocomplete.css b/static/admin/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/static/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/static/admin/css/base.css b/static/admin/css/base.css new file mode 100644 index 0000000..72f4ae1 --- /dev/null +++ b/static/admin/css/base.css @@ -0,0 +1,1138 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.base-svgs { + display: none; +} diff --git a/static/admin/css/changelists.css b/static/admin/css/changelists.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/static/admin/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/static/admin/css/dark_mode.css b/static/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/static/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/static/admin/css/dashboard.css b/static/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/static/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/static/admin/css/fonts.css b/static/admin/css/fonts.css new file mode 100644 index 0000000..c837e01 --- /dev/null +++ b/static/admin/css/fonts.css @@ -0,0 +1,20 @@ +@font-face { + font-family: 'Roboto'; + src: url('../fonts/Roboto-Bold-webfont.woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Roboto'; + src: url('../fonts/Roboto-Regular-webfont.woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Roboto'; + src: url('../fonts/Roboto-Light-webfont.woff'); + font-weight: 300; + font-style: normal; +} diff --git a/static/admin/css/forms.css b/static/admin/css/forms.css new file mode 100644 index 0000000..e45abe1 --- /dev/null +++ b/static/admin/css/forms.css @@ -0,0 +1,530 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/static/admin/css/login.css b/static/admin/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/static/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/static/admin/css/nav_sidebar.css b/static/admin/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/static/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/static/admin/css/responsive.css b/static/admin/css/responsive.css new file mode 100644 index 0000000..9ce4f67 --- /dev/null +++ b/static/admin/css/responsive.css @@ -0,0 +1,998 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row { + align-items: center; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/static/admin/css/responsive_rtl.css b/static/admin/css/responsive_rtl.css new file mode 100644 index 0000000..639e20b --- /dev/null +++ b/static/admin/css/responsive_rtl.css @@ -0,0 +1,81 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } +} diff --git a/static/admin/css/rtl.css b/static/admin/css/rtl.css new file mode 100644 index 0000000..53a6dd6 --- /dev/null +++ b/static/admin/css/rtl.css @@ -0,0 +1,288 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/static/admin/css/vendor/select2/LICENSE-SELECT2.md b/static/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/static/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/static/admin/css/vendor/select2/select2.css b/static/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/static/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/static/admin/css/vendor/select2/select2.min.css b/static/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/static/admin/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/admin/css/widgets.css b/static/admin/css/widgets.css new file mode 100644 index 0000000..5f7adcb --- /dev/null +++ b/static/admin/css/widgets.css @@ -0,0 +1,603 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/static/admin/fonts/LICENSE.txt b/static/admin/fonts/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/static/admin/fonts/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/static/admin/fonts/README.txt b/static/admin/fonts/README.txt new file mode 100644 index 0000000..b247bef --- /dev/null +++ b/static/admin/fonts/README.txt @@ -0,0 +1,3 @@ +Roboto webfont source: https://www.google.com/fonts/specimen/Roboto +WOFF files extracted using https://github.com/majodev/google-webfonts-helper +Weights used in this project: Light (300), Regular (400), Bold (700) diff --git a/static/admin/fonts/Roboto-Bold-webfont.woff b/static/admin/fonts/Roboto-Bold-webfont.woff new file mode 100644 index 0000000..6e0f562 Binary files /dev/null and b/static/admin/fonts/Roboto-Bold-webfont.woff differ diff --git a/static/admin/fonts/Roboto-Light-webfont.woff b/static/admin/fonts/Roboto-Light-webfont.woff new file mode 100644 index 0000000..b9e9918 Binary files /dev/null and b/static/admin/fonts/Roboto-Light-webfont.woff differ diff --git a/static/admin/fonts/Roboto-Regular-webfont.woff b/static/admin/fonts/Roboto-Regular-webfont.woff new file mode 100644 index 0000000..96c1986 Binary files /dev/null and b/static/admin/fonts/Roboto-Regular-webfont.woff differ diff --git a/static/admin/img/LICENSE b/static/admin/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/static/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/img/README.txt b/static/admin/img/README.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/static/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/static/admin/img/calendar-icons.svg b/static/admin/img/calendar-icons.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/static/admin/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/admin/img/gis/move_vertex_off.svg b/static/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/static/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/gis/move_vertex_on.svg b/static/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/static/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/icon-addlink.svg b/static/admin/img/icon-addlink.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/static/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-alert.svg b/static/admin/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/static/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-calendar.svg b/static/admin/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/static/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-changelink.svg b/static/admin/img/icon-changelink.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/static/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-clock.svg b/static/admin/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/static/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-deletelink.svg b/static/admin/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/static/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-no.svg b/static/admin/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/static/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown-alt.svg b/static/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/static/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown.svg b/static/admin/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/static/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-viewlink.svg b/static/admin/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/static/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-yes.svg b/static/admin/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/static/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/inline-delete.svg b/static/admin/img/inline-delete.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/static/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/search.svg b/static/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/static/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/selector-icons.svg b/static/admin/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/static/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/sorting-icons.svg b/static/admin/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/static/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/tooltag-add.svg b/static/admin/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/static/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/tooltag-arrowright.svg b/static/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/static/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/js/SelectBox.js b/static/admin/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/static/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/static/admin/js/SelectFilter2.js b/static/admin/js/SelectFilter2.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/static/admin/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
    + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
    + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/static/admin/js/actions.js b/static/admin/js/actions.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/static/admin/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/static/admin/js/actions.min.js b/static/admin/js/actions.min.js new file mode 100644 index 0000000..29fd0d8 --- /dev/null +++ b/static/admin/js/actions.min.js @@ -0,0 +1,7 @@ +'use strict';{const a=django.jQuery;let e;a.fn.actions=function(g){const b=a.extend({},a.fn.actions.defaults,g),f=a(this);let k=!1;const l=function(){a(b.acrossClears).hide();a(b.acrossQuestions).show();a(b.allContainer).hide()},m=function(){a(b.acrossClears).show();a(b.acrossQuestions).hide();a(b.actionContainer).toggleClass(b.selectedClass);a(b.allContainer).show();a(b.counterContainer).hide()},n=function(){a(b.acrossClears).hide();a(b.acrossQuestions).hide();a(b.allContainer).hide();a(b.counterContainer).show()}, +p=function(){n();a(b.acrossInput).val(0);a(b.actionContainer).removeClass(b.selectedClass)},q=function(c){c?l():n();a(f).prop("checked",c).parent().parent().toggleClass(b.selectedClass,c)},h=function(){const c=a(f).filter(":checked").length,d=a(".action-counter").data("actionsIcnt");a(b.counterContainer).html(interpolate(ngettext("%(sel)s of %(cnt)s selected","%(sel)s of %(cnt)s selected",c),{sel:c,cnt:d},!0));a(b.allToggle).prop("checked",function(){let a;c===f.length?(a=!0,l()):(a=!1,p());return a})}; +a(b.counterContainer).show();a(this).filter(":checked").each(function(c){a(this).parent().parent().toggleClass(b.selectedClass);h();1===a(b.acrossInput).val()&&m()});a(b.allToggle).show().on("click",function(){q(a(this).prop("checked"));h()});a("a",b.acrossQuestions).on("click",function(c){c.preventDefault();a(b.acrossInput).val(1);m()});a("a",b.acrossClears).on("click",function(c){c.preventDefault();a(b.allToggle).prop("checked",!1);p();q(0);h()});e=null;a(f).on("click",function(c){c||(c=window.event); +const d=c.target?c.target:c.srcElement;if(e&&a.data(e)!==a.data(d)&&!0===c.shiftKey){let c=!1;a(e).prop("checked",d.checked).parent().parent().toggleClass(b.selectedClass,d.checked);a(f).each(function(){if(a.data(this)===a.data(e)||a.data(this)===a.data(d))c=c?!1:!0;c&&a(this).prop("checked",d.checked).parent().parent().toggleClass(b.selectedClass,d.checked)})}a(d).parent().parent().toggleClass(b.selectedClass,d.checked);e=d;h()});a("form#changelist-form table#result_list tr").on("change","td:gt(0) :input", +function(){k=!0});a('form#changelist-form button[name="index"]').on("click",function(a){if(k)return confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost."))});a('form#changelist-form input[name="_save"]').on("click",function(c){let d=!1;a("select option:selected",b.actionContainer).each(function(){a(this).val()&&(d=!0)});if(d)return k?confirm(gettext("You have selected an action, but you haven\u2019t saved your changes to individual fields yet. Please click OK to save. You\u2019ll need to re-run the action.")): +confirm(gettext("You have selected an action, and you haven\u2019t made any changes on individual fields. You\u2019re probably looking for the Go button rather than the Save button."))})};a.fn.actions.defaults={actionContainer:"div.actions",counterContainer:"span.action-counter",allContainer:"div.actions span.all",acrossInput:"div.actions input.select-across",acrossQuestions:"div.actions span.question",acrossClears:"div.actions span.clear",allToggle:"#action-toggle",selectedClass:"selected"};a(document).ready(function(){const g= +a("tr input.action-select");0 +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled + calendarDivName2: 'calendarin', // name of
    that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
    that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
    + //

    Choose a time

    + // + //

    Cancel

    + //
    + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
    + //

    + // + // February 2003 + //

    + //
    + // + //
    + //
    + // Yesterday | Today | Tomorrow + //
    + //

    Cancel

    + //
    + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/static/admin/js/admin/RelatedObjectLookups.js b/static/admin/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/static/admin/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/static/admin/js/autocomplete.js b/static/admin/js/autocomplete.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/static/admin/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/static/admin/js/calendar.js b/static/admin/js/calendar.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/static/admin/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/static/admin/js/cancel.js b/static/admin/js/cancel.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/static/admin/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/static/admin/js/change_form.js b/static/admin/js/change_form.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/static/admin/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/static/admin/js/collapse.js b/static/admin/js/collapse.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/static/admin/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/static/admin/js/collapse.min.js b/static/admin/js/collapse.min.js new file mode 100644 index 0000000..06201c5 --- /dev/null +++ b/static/admin/js/collapse.min.js @@ -0,0 +1,2 @@ +'use strict';window.addEventListener("load",function(){var c=document.querySelectorAll("fieldset.collapse");for(const [a,b]of c.entries())if(0===b.querySelectorAll("div.errors, ul.errorlist").length){b.classList.add("collapsed");c=b.querySelector("h2");const d=document.createElement("a");d.id="fieldsetcollapser"+a;d.className="collapse-toggle";d.href="#";d.textContent=gettext("Show");c.appendChild(document.createTextNode(" ("));c.appendChild(d);c.appendChild(document.createTextNode(")"))}const e= +function(a){if(a.target.matches(".collapse-toggle")){a.preventDefault();a.stopPropagation();const b=a.target.closest("fieldset");b.classList.contains("collapsed")?(a.target.textContent=gettext("Hide"),b.classList.remove("collapsed")):(a.target.textContent=gettext("Show"),b.classList.add("collapsed"))}};document.querySelectorAll("fieldset.module").forEach(function(a){a.addEventListener("click",e)})}); diff --git a/static/admin/js/core.js b/static/admin/js/core.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/static/admin/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/static/admin/js/filters.js b/static/admin/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/static/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/static/admin/js/inlines.js b/static/admin/js/inlines.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/static/admin/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + options.deleteText + "
  • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/static/admin/js/inlines.min.js b/static/admin/js/inlines.min.js new file mode 100644 index 0000000..fc6dddc --- /dev/null +++ b/static/admin/js/inlines.min.js @@ -0,0 +1,11 @@ +'use strict';{const b=django.jQuery;b.fn.formset=function(c){const a=b.extend({},b.fn.formset.defaults,c),e=b(this),l=e.parent(),m=function(a,d,h){const g=new RegExp("("+d+"-(\\d+|__prefix__))");d=d+"-"+h;b(a).prop("for")&&b(a).prop("for",b(a).prop("for").replace(g,d));a.id&&(a.id=a.id.replace(g,d));a.name&&(a.name=a.name.replace(g,d))},f=b("#id_"+a.prefix+"-TOTAL_FORMS").prop("autocomplete","off");let n=parseInt(f.val(),10);const h=b("#id_"+a.prefix+"-MAX_NUM_FORMS").prop("autocomplete","off"),q= +b("#id_"+a.prefix+"-MIN_NUM_FORMS").prop("autocomplete","off");let k;const t=function(g){g.preventDefault();g=b("#"+a.prefix+"-empty");const d=g.clone(!0);d.removeClass(a.emptyCssClass).addClass(a.formCssClass).attr("id",a.prefix+"-"+n);r(d);d.find("*").each(function(){m(this,a.prefix,f.val())});d.insertBefore(b(g));b(f).val(parseInt(f.val(),10)+1);n+=1;""!==h.val()&&0>=h.val()-f.val()&&k.parent().hide();p(d.closest(".inline-group"));a.added&&a.added(d);b(document).trigger("formset:added",[d,a.prefix])}, +r=function(b){b.is("tr")?b.children(":last").append('"):b.is("ul")||b.is("ol")?b.append('
  • '+a.deleteText+"
  • "):b.children(":first").append(''+a.deleteText+"");b.find("a."+a.deleteCssClass).on("click",u.bind(this))},u=function(g){g.preventDefault();var d=b(g.target).closest("."+a.formCssClass);g=d.closest(".inline-group"); +var f=d.prev();f.length&&f.hasClass("row-form-errors")&&f.remove();d.remove();--n;a.removed&&a.removed(d);b(document).trigger("formset:removed",[d,a.prefix]);d=b("."+a.formCssClass);b("#id_"+a.prefix+"-TOTAL_FORMS").val(d.length);(""===h.val()||0'+a.addText+"");k=l.find("tr:last a")}else e.filter(":last").after('"), +k=e.filter(":last").next().find("a");k.on("click",t)})();c=""===h.val()||0 tr.form-row",b(c).tabularFormset(c,a.options)}})})}; diff --git a/static/admin/js/jquery.init.js b/static/admin/js/jquery.init.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/static/admin/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/static/admin/js/nav_sidebar.js b/static/admin/js/nav_sidebar.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/static/admin/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/static/admin/js/popup_response.js b/static/admin/js/popup_response.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/static/admin/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/static/admin/js/prepopulate.js b/static/admin/js/prepopulate.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/static/admin/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/static/admin/js/prepopulate.min.js b/static/admin/js/prepopulate.min.js new file mode 100644 index 0000000..11ead49 --- /dev/null +++ b/static/admin/js/prepopulate.min.js @@ -0,0 +1 @@ +'use strict';{const b=django.jQuery;b.fn.prepopulate=function(d,f,g){return this.each(function(){const a=b(this),h=function(){if(!a.data("_changed")){var e=[];b.each(d,function(a,c){c=b(c);0 Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/static/admin/js/urlify.js b/static/admin/js/urlify.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/static/admin/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/static/admin/js/vendor/jquery/LICENSE.txt b/static/admin/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/static/admin/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/js/vendor/jquery/jquery.js b/static/admin/js/vendor/jquery/jquery.js new file mode 100644 index 0000000..7f35c11 --- /dev/null +++ b/static/admin/js/vendor/jquery/jquery.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `