1. 重新初始化

This commit is contained in:
ngfchl
2022-08-24 16:04:12 +08:00
commit 7cb8fd1ef3
381 changed files with 82960 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/venv/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="db.sqlite3" uuid="ec8a7554-0439-4c3a-93c8-17cfab1cc1af">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db/db.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,132 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="60" name="Python" />
</Languages>
</inspection_tool>
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="height" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="65">
<item index="0" class="java.lang.String" itemvalue="Scrapy" />
<item index="1" class="java.lang.String" itemvalue="amqp" />
<item index="2" class="java.lang.String" itemvalue="wcwidth" />
<item index="3" class="java.lang.String" itemvalue="kombu" />
<item index="4" class="java.lang.String" itemvalue="click" />
<item index="5" class="java.lang.String" itemvalue="click-didyoumean" />
<item index="6" class="java.lang.String" itemvalue="celery" />
<item index="7" class="java.lang.String" itemvalue="prompt-toolkit" />
<item index="8" class="java.lang.String" itemvalue="click-repl" />
<item index="9" class="java.lang.String" itemvalue="pytz" />
<item index="10" class="java.lang.String" itemvalue="click-plugins" />
<item index="11" class="java.lang.String" itemvalue="vine" />
<item index="12" class="java.lang.String" itemvalue="billiard" />
<item index="13" class="java.lang.String" itemvalue="simpleui" />
<item index="14" class="java.lang.String" itemvalue="protobuf" />
<item index="15" class="java.lang.String" itemvalue="paddleocr" />
<item index="16" class="java.lang.String" itemvalue="Babel" />
<item index="17" class="java.lang.String" itemvalue="opt-einsum" />
<item index="18" class="java.lang.String" itemvalue="bce-python-sdk" />
<item index="19" class="java.lang.String" itemvalue="python-dateutil" />
<item index="20" class="java.lang.String" itemvalue="cycler" />
<item index="21" class="java.lang.String" itemvalue="MarkupSafe" />
<item index="22" class="java.lang.String" itemvalue="pyclipper" />
<item index="23" class="java.lang.String" itemvalue="lmdb" />
<item index="24" class="java.lang.String" itemvalue="paddlepaddle" />
<item index="25" class="java.lang.String" itemvalue="premailer" />
<item index="26" class="java.lang.String" itemvalue="paddle-bfloat" />
<item index="27" class="java.lang.String" itemvalue="opencv-contrib-python" />
<item index="28" class="java.lang.String" itemvalue="tifffile" />
<item index="29" class="java.lang.String" itemvalue="Werkzeug" />
<item index="30" class="java.lang.String" itemvalue="kiwisolver" />
<item index="31" class="java.lang.String" itemvalue="attrdict" />
<item index="32" class="java.lang.String" itemvalue="fonttools" />
<item index="33" class="java.lang.String" itemvalue="imageio" />
<item index="34" class="java.lang.String" itemvalue="matplotlib" />
<item index="35" class="java.lang.String" itemvalue="Flask-Babel" />
<item index="36" class="java.lang.String" itemvalue="scikit-image" />
<item index="37" class="java.lang.String" itemvalue="decorator" />
<item index="38" class="java.lang.String" itemvalue="networkx" />
<item index="39" class="java.lang.String" itemvalue="python-Levenshtein" />
<item index="40" class="java.lang.String" itemvalue="numpy" />
<item index="41" class="java.lang.String" itemvalue="imgaug" />
<item index="42" class="java.lang.String" itemvalue="importlib-metadata" />
<item index="43" class="java.lang.String" itemvalue="Jinja2" />
<item index="44" class="java.lang.String" itemvalue="PyWavelets" />
<item index="45" class="java.lang.String" itemvalue="zipp" />
<item index="46" class="java.lang.String" itemvalue="qbittorrent-api" />
<item index="47" class="java.lang.String" itemvalue="urllib3" />
<item index="48" class="java.lang.String" itemvalue="itsdangerous" />
<item index="49" class="java.lang.String" itemvalue="visualdl" />
<item index="50" class="java.lang.String" itemvalue="Cython" />
<item index="51" class="java.lang.String" itemvalue="Flask" />
<item index="52" class="java.lang.String" itemvalue="scipy" />
<item index="53" class="java.lang.String" itemvalue="opencv-python" />
<item index="54" class="java.lang.String" itemvalue="Shapely" />
<item index="55" class="java.lang.String" itemvalue="tzdata" />
<item index="56" class="java.lang.String" itemvalue="astor" />
<item index="57" class="java.lang.String" itemvalue="cssutils" />
<item index="58" class="java.lang.String" itemvalue="pandas" />
<item index="59" class="java.lang.String" itemvalue="tqdm" />
<item index="60" class="java.lang.String" itemvalue="Django" />
<item index="61" class="java.lang.String" itemvalue="future" />
<item index="62" class="java.lang.String" itemvalue="cachetools" />
<item index="63" class="java.lang.String" itemvalue="pycryptodome" />
<item index="64" class="java.lang.String" itemvalue="Pillow" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N803" />
<option value="N802" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (ptools)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ptools.iml" filepath="$PROJECT_DIR$/.idea/ptools.iml" />
</modules>
</component>
</project>

34
.idea/ptools.iml generated Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="ptools/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# myproject/Dockerfile
# 建立 python3.9 环境
FROM python:3.9.13
# 镜像作者大江狗
MAINTAINER ngfchl ngfchl@126.com
# 设置 python 环境变量
ENV PYTHONUNBUFFERED 1
ENV DJANGO_SUPERUSER_USERNAME=admin
ENV DJANGO_SUPERUSER_EMAIL=admin@eamil.com
ENV DJANGO_SUPERUSER_PASSWORD=adminadmin
COPY pip.conf /root/.pip/pip.conf
# 创建 myproject 文件夹
RUN mkdir -p /var/www/html/ptools
# 将 myproject 文件夹为工作目录
WORKDIR /var/www/html/ptools
# 将当前目录加入到工作目录中(. 表示当前目录)
ADD . /var/www/html/ptools
# 更新pip版本
RUN /usr/local/bin/python -m pip install --upgrade pip
# 利用 pip 安装依赖
#RUN pip install -r requirements.txt
# 去除windows系统编辑文件中多余的\r回车空格
RUN sed -i 's/\r//' ./start.sh
# 给start.sh可执行权限
RUN chmod +x ./start.sh
RUN chmod +x ./update.sh
# 安装redis
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
#RUN apt update
#RUN yes|apt install libgl1-mesa-glx
#RUN yes|apt install redis-server
VOLUME ["/var/www/html/ptools/db"]
EXPOSE 8000
#ENTRYPOINT ["redis-server","/etc/redis/redis.conf"]
#ENTRYPOINT ["/bin/bash", "first.sh"]
ENTRYPOINT ["/bin/bash", "start.sh"]

1
auto_pt/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'auto_pt.apps.AutoPtConfig'

215
auto_pt/admin.py Normal file
View File

@@ -0,0 +1,215 @@
import logging
from datetime import datetime
from apscheduler.triggers.cron import CronTrigger
from django.contrib import admin, messages
from django.http import JsonResponse
from import_export.admin import ImportExportModelAdmin
from simpleui.admin import AjaxAdmin
from auto_pt.models import Task, TaskJob, Notify, OCR
from pt_site import views as tasks
from pt_site.views import pt_spider
from ptools.base import Trigger
# Register your models here.
@admin.register(Task)
class TaskAdmin(ImportExportModelAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = (
'desc',
'name',
)
# list_display_links = None
search_fields = ('name',)
readonly_fields = ('name',)
def get_queryset(self, request):
# print(self.kwargs['username'])
data = [key for key in dir(tasks) if key.startswith('auto_')]
for task in data:
Task.objects.update_or_create(name=task, defaults={'desc': getattr(tasks, task).__doc__.strip()})
return Task.objects.all()
# 禁止添加按钮
def has_add_permission(self, request):
return False
# 禁止删除按钮
def has_delete_permission(self, request, obj=None):
return False
# 禁止修改按钮
# def has_change_permission(self, request, obj=None):
# return False
@admin.register(TaskJob)
class TaskJobAdmin(ImportExportModelAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = (
'job_id',
'task',
'trigger',
'task_exec',
'replace_existing',
'updated_at',
)
search_fields = ('task', 'job_id')
list_filter = ('task', 'trigger', 'task_exec',)
autocomplete_fields = ('task',)
list_editable = ('task_exec',)
def save_model(self, request, obj: TaskJob, form, change):
obj.save()
try:
# 从字符串获取function
func = getattr(tasks, obj.task.name)
exist_job = tasks.scheduler.get_job(obj.job_id)
new_job = None
if obj.trigger == Trigger.cron:
new_job = tasks.scheduler.add_job(func,
trigger=CronTrigger.from_crontab(obj.expression_time),
id=obj.job_id,
replace_existing=obj.replace_existing,
misfire_grace_time=obj.misfire_grace_time,
jitter=obj.jitter, )
if obj.trigger == Trigger.interval:
time_delta = 1
time_str = obj.expression_time.split('*')
for i in time_str:
time_delta *= int(i)
new_job = tasks.scheduler.add_job(func,
trigger=obj.trigger,
id=obj.job_id,
seconds=time_delta,
replace_existing=obj.replace_existing,
misfire_grace_time=obj.misfire_grace_time,
jitter=obj.jitter, )
if not obj.task_exec:
"""如果任务未启用,入库后保持暂停"""
new_job.pause()
print(new_job.pending)
pt_spider.send_text('计划任务:' + new_job.id + (' 添加成功!' if not exist_job else '更新成功!'))
messages.add_message(request, messages.SUCCESS, new_job.id + (' 添加成功!' if not exist_job else '更新成功!'))
except Exception as e:
obj.task_exec = False
obj.save()
pt_spider.send_text('计划任务:' + obj.job_id + '任务添加失败!原因:' + str(e))
messages.add_message(request, messages.ERROR, obj.job_id + '任务添加失败!原因:' + str(e))
def delete_model(self, request, obj):
print(obj)
# DjangoJob.objects.filter(obj.job_id).delete()
tasks.scheduler.get_job(obj.job_id).remove()
logging.info('计划任务:' + obj.job_id + ' 取消成功!')
pt_spider.send_text('计划任务:' + obj.job_id + ' 取消成功!')
obj.delete()
def delete_queryset(self, request, queryset):
for obj in queryset:
job = tasks.scheduler.get_job(obj.job_id)
if job:
job.remove()
logging.info('计划任务:' + obj.job_id + ' 取消成功!')
pt_spider.send_text('计划任务:' + obj.job_id + ' 取消成功!')
queryset.delete()
# def delete_view(self, request, object_id, extra_context=None):
# print(object_id)
@admin.register(Notify)
class NotifyAdmin(ImportExportModelAdmin, AjaxAdmin):
# formats = (base_formats.XLS, base_formats.CSV)
list_display = [
'name',
'enable',
'agentid',
'updated_at',
]
search_fields = ('name',)
list_filter = ('name',)
list_editable = ['enable']
actions = ['test_notify']
def test_notify(self, request, queryset):
post = request.POST
text = post.get('text')
print(text)
try:
res = pt_spider.send_text(text)
return JsonResponse(data={
'status': 'success',
'msg': res
})
except Exception as e:
print(e)
# 显示的文本与django admin一致
test_notify.short_description = '通知测试'
# icon参考element-ui icon与https://fontawesome.com
test_notify.icon = 'el-icon-star-on'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
test_notify.type = 'warning'
# def has_add_permission(self, request):
# # 保证只有一条记录
# count = Notify.objects.all().count()
# if count != 1:
# return True
# return False
test_notify.layer = {
# 弹出层中的输入框配置
# 这里指定对话框的标题
'title': '通知测试',
# 提示信息
'tips': '异步获取配置' + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# 确认按钮显示文本
'confirm_button': '发送通知',
# 取消按钮显示文本
'cancel_button': '取消',
# 弹出层对话框的宽度默认50%
'width': '40%',
# 表单中 label的宽度对应element-ui的 label-width默认80px
'labelWidth': "80px",
'params': [
{
# 这里的type 对应el-input的原生input属性默认为input
'type': 'input',
# key 对应post参数中的key
'key': 'text',
# 显示的文本
'label': '测试消息',
# 为空校验默认为False
'require': True
}]
}
@admin.register(OCR)
class OCRAdmin(ImportExportModelAdmin):
list_display = [
'name',
'enable',
'app_id',
'updated_at',
]
search_fields = ('name',)
list_filter = ('name', 'enable')
def has_add_permission(self, request):
# 保证只有一条记录
count = Notify.objects.all().count()
if count != 1:
return True
return False

8
auto_pt/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AutoPtConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'auto_pt'
verbose_name = '计划任务'

View File

@@ -0,0 +1,289 @@
# Generated by Django 4.1 on 2022-08-24 15:08
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Notify",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"name",
models.CharField(
choices=[
("wechat_work_push", "企业微信通知"),
("wxpusher_push", "WxPusher通知"),
("pushdeer_push", "PushDeer通知"),
("bark_push", "Bark通知"),
],
default="wechat_work_push",
max_length=64,
verbose_name="通知方式",
),
),
(
"enable",
models.BooleanField(
default=True, help_text="只有开启才能发送哦!", verbose_name="开启通知"
),
),
(
"corpid",
models.CharField(
blank=True,
help_text="微信企业ID",
max_length=64,
null=True,
verbose_name="企业ID",
),
),
(
"corpsecret",
models.CharField(
blank=True,
help_text="应用的Secret/Token",
max_length=64,
null=True,
verbose_name="Secret",
),
),
(
"agentid",
models.CharField(
blank=True,
help_text="APP ID",
max_length=64,
null=True,
verbose_name="应用ID",
),
),
(
"touser",
models.CharField(
blank=True,
help_text="接收者用户名/UID",
max_length=64,
null=True,
verbose_name="接收者",
),
),
(
"custom_server",
models.URLField(
blank=True,
help_text="无自定义服务器的,请勿填写!",
null=True,
verbose_name="自定义服务器",
),
),
],
options={"verbose_name": "通知推送", "verbose_name_plural": "通知推送",},
),
migrations.CreateModel(
name="OCR",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"name",
models.CharField(
choices=[("baidu_aip", "百度OCR")],
default="baidu_aip",
max_length=64,
verbose_name="OCR",
),
),
("enable", models.BooleanField(default=False, verbose_name="启用")),
(
"api_key",
models.CharField(
blank=True, max_length=64, null=True, verbose_name="API-Key"
),
),
(
"secret_key",
models.CharField(
blank=True,
help_text="应用的Secret",
max_length=64,
null=True,
verbose_name="Secret",
),
),
(
"app_id",
models.CharField(
blank=True,
help_text="APP ID",
max_length=64,
null=True,
verbose_name="应用ID",
),
),
],
options={"verbose_name": "OCR识别", "verbose_name_plural": "OCR识别",},
),
migrations.CreateModel(
name="Task",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
("name", models.CharField(max_length=32, verbose_name="任务名称")),
("desc", models.CharField(max_length=32, verbose_name="任务描述")),
],
options={
"verbose_name": "任务",
"verbose_name_plural": "任务",
"ordering": ("name",),
},
),
migrations.CreateModel(
name="TaskJob",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"job_id",
models.CharField(max_length=16, unique=True, verbose_name="任务ID"),
),
(
"trigger",
models.CharField(
choices=[("interval", "间隔任务"), ("cron", "cron任务")],
default="cron",
max_length=64,
verbose_name="任务类型",
),
),
("task_exec", models.BooleanField(default=False, verbose_name="开启任务")),
(
"replace_existing",
models.BooleanField(
default=True,
help_text="不设置此项重启项目后会报任务id已存在的错误, 设置此参数后会对已有的任务进行覆盖",
verbose_name="覆盖任务",
),
),
(
"expression_time",
models.CharField(
help_text="在间隔任务表示间隔时长使用数字单位corn任务中为corn表达式“0 15 8 ? * * 2022”",
max_length=64,
verbose_name="时间表达式",
),
),
(
"start_date",
models.DateTimeField(blank=True, null=True, verbose_name="任务开始时间"),
),
(
"end_date",
models.DateTimeField(blank=True, null=True, verbose_name="任务结束时间"),
),
(
"misfire_grace_time",
models.IntegerField(
default=120,
help_text="强制执行结束的时间, 为避免撞车导致任务丢失, 没执行完就别执行了",
verbose_name="任务运行时间",
),
),
(
"jitter",
models.IntegerField(
default=120,
help_text="强制执行结束的时间, 为避免撞车导致任务丢失, 没执行完就别执行了",
verbose_name="时间浮动参数",
),
),
(
"args",
models.CharField(
blank=True,
help_text="执行代码所需要的参数。",
max_length=128,
null=True,
verbose_name="任务参数",
),
),
(
"task",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="auto_pt.task",
verbose_name="任务名称",
),
),
],
options={"verbose_name": "计划任务", "verbose_name_plural": "计划任务",},
),
]

View File

111
auto_pt/models.py Normal file
View File

@@ -0,0 +1,111 @@
# Create your models here.
from django.db import models
from ptools.base import BaseEntity, Trigger, PushConfig, OCRConfig
class Task(BaseEntity):
name = models.CharField(verbose_name='任务名称', max_length=32)
desc = models.CharField(verbose_name='任务描述', max_length=32)
def __str__(self):
return self.desc
class Meta:
verbose_name = '任务'
verbose_name_plural = verbose_name
ordering = ('name',)
class TaskJob(BaseEntity):
"""
trigger:    'date''interval''cron'
id:   任务的名字,不传的话会自动生成。不过为了之后对任务进行暂停、开启、删除等操作,建议给一个名字。并且是唯一的,如果多个任务取一个名字,之前的任务就会被覆盖。
args:   list 执行代码所需要的参数。
replace_existing:   默认不设置的话回导致重启项目后, 爆id已存在的错误, 设置此参数后会对已有的 id 进行覆盖从而避免报错
next_run_time  datetime 开始执行时间
misfire_grace_time:   强制执行结束的时间, 为避免撞车导致任务丢失, 没执行完就别执行了
"""
task = models.ForeignKey(verbose_name='任务名称', to=Task, on_delete=models.CASCADE)
job_id = models.CharField(verbose_name='任务ID', max_length=16, unique=True)
trigger = models.CharField(verbose_name='任务类型', choices=Trigger.choices, default=Trigger.cron, max_length=64)
task_exec = models.BooleanField(verbose_name='开启任务', default=False)
replace_existing = models.BooleanField(verbose_name='覆盖任务', default=True,
help_text='不设置此项重启项目后会报任务id已存在的错误, 设置此参数后会对已有的任务进行覆盖')
expression_time = models.CharField(verbose_name='时间表达式',
help_text='在间隔任务表示间隔时长使用数字单位corn任务中为corn表达式“0 15 8 ? * * 2022”',
max_length=64)
start_date = models.DateTimeField(verbose_name='任务开始时间', null=True, blank=True)
end_date = models.DateTimeField(verbose_name='任务结束时间', null=True, blank=True)
misfire_grace_time = models.IntegerField(verbose_name='任务运行时间', default=120,
help_text='强制执行结束的时间, 为避免撞车导致任务丢失, 没执行完就别执行了')
jitter = models.IntegerField(verbose_name='时间浮动参数', default=120,
help_text='强制执行结束的时间, 为避免撞车导致任务丢失, 没执行完就别执行了')
args = models.CharField(verbose_name='任务参数',
help_text='执行代码所需要的参数。',
max_length=128, null=True, blank=True)
def __str__(self):
return self.task.name
class Meta:
verbose_name = '计划任务'
verbose_name_plural = verbose_name
class Notify(BaseEntity):
"""
corpid=企业ID在管理后台获取
corpsecret: 自建应用的Secret每个自建应用里都有单独的secret
agentid: 应用ID在后台应用中获取
touser: 接收者用户名(微信账号), 多个用户用 | 分割, 与发送消息的touser至少存在一个
"""
name = models.CharField(verbose_name='通知方式', choices=PushConfig.choices, default=PushConfig.wechat_work_push,
max_length=64)
enable = models.BooleanField(verbose_name='开启通知', default=True, help_text='只有开启才能发送哦!')
corpid = models.CharField(verbose_name='企业ID', max_length=64,
help_text='微信企业ID', null=True, blank=True)
corpsecret = models.CharField(verbose_name='Secret', max_length=64,
help_text='应用的Secret/Token', null=True, blank=True)
agentid = models.CharField(verbose_name='应用ID', max_length=64,
help_text='APP ID', null=True, blank=True)
touser = models.CharField(verbose_name='接收者', max_length=64,
help_text='接收者用户名/UID',
null=True, blank=True)
custom_server = models.URLField(verbose_name='自定义服务器', null=True, blank=True, help_text='无自定义服务器的,请勿填写!')
class Meta:
verbose_name = '通知推送'
verbose_name_plural = verbose_name
class OCR(BaseEntity):
"""
corpid=企业ID在管理后台获取
corpsecret: 自建应用的Secret每个自建应用里都有单独的secret
agentid: 应用ID在后台应用中获取
app_id = '2695'
api_key = 'TUoKvq3w1d'
secret_key = 'XojLDC9s5qc'
"""
name = models.CharField(verbose_name='OCR', choices=OCRConfig.choices, default=OCRConfig.baidu_aip, max_length=64)
enable = models.BooleanField(verbose_name='启用', default=False)
api_key = models.CharField(verbose_name='API-Key',
max_length=64,
null=True, blank=True)
secret_key = models.CharField(verbose_name='Secret',
max_length=64,
help_text='应用的Secret',
null=True, blank=True)
app_id = models.CharField(verbose_name='应用ID',
max_length=64,
help_text='APP ID',
null=True, blank=True)
def __str__(self):
return self.name
class Meta:
verbose_name = 'OCR识别'
verbose_name_plural = verbose_name

3
auto_pt/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

13
auto_pt/urls.py Normal file
View File

@@ -0,0 +1,13 @@
from django.urls import path
from auto_pt import views
urlpatterns = [
path(r'get_tasks', views.get_tasks, name='get_tasks'),
path(r'add_task', views.get_tasks, name='add_task'),
path(r'exec_task', views.exec_task, name='exec_task'),
path(r'test_field', views.test_field, name='test_field'),
path(r'test_notify', views.test_notify, name='test_notify'),
path(r'restart', views.restart_container, name='restart_container'),
path(r'do_restart', views.do_restart, name='do_restart'),
]

104
auto_pt/views.py Normal file
View File

@@ -0,0 +1,104 @@
import json
import subprocess
from datetime import datetime
import markdown
from django.http import JsonResponse
from django.shortcuts import render
from pt_site import views as tasks
from pt_site.models import SiteStatus, MySite
from pt_site.views import scheduler, pt_spider
from ptools.base import CommonResponse
def add_task(request):
if request.method == 'POST':
content = json.loads(request.body.decode()) # 接收参数
try:
start_time = content['start_time'] # 用户输入的任务开始时间, '10:00:00'
start_time = start_time.split(':')
hour = int(start_time[0])
minute = int(start_time[1])
second = int(start_time[2])
s = content['s'] # 接收执行任务的各种参数
# 创建任务
scheduler.add_job(tasks.scheduler, 'cron', hour=hour, minute=minute, second=second, args=[s])
code = '200'
message = 'success'
except Exception as e:
code = '400'
message = e
data = {
'code': code,
'message': message
}
return JsonResponse(json.dumps(data, ensure_ascii=False), safe=False)
def get_tasks(request):
# print(dir(tasks))
data = [key for key in dir(tasks) if key.startswith('auto')]
print(data)
# print(tasks.__getattr__)
# print(tasks.auto_get_status.__doc__)
# inspect.getmembers(tasks, inspect.isfunction)
# inspect.getmodule(tasks)
# print(sys.modules[__name__])
# print(sys.modules.values())
# print(sys.modules.keys())
# print(sys.modules.items())
return JsonResponse('ok', safe=False)
def exec_task(request):
# res = AutoPt.auto_sign_in()
# print(res)
tasks.auto_sign_in.delay()
return JsonResponse('ok!', safe=False)
def test_field(request):
my_site = MySite.objects.get(pk=1)
list1 = SiteStatus.objects.filter(site=my_site, created_at__date__gte=datetime.today())
print(list1)
return JsonResponse('ok!', safe=False)
def test_notify(request):
"""
app_id28987
uid: UID_jkMs0DaVVwOcBuFPQGzymjCwYVgH
应用名称pt_helper
appTokenAT_ShUnRu2CJRcsqbbW540voVkjMZ1PKjGy
"""
# res = NotifyDispatch().send_text(text='66666')
res = pt_spider.send_text('666')
print(res)
return JsonResponse(res, safe=False)
def do_restart(request):
try:
print('重启')
# print(os.system('pwd'))
subprocess.Popen('./update.sh')
return JsonResponse(data=CommonResponse.success(
msg='重启指令发送成功!!'
).to_dict(), safe=False)
except Exception as e:
return JsonResponse(data=CommonResponse.error(
msg='重启指令发送失败!' + str(e)
).to_dict(), safe=False)
def restart_container(request):
scraper = pt_spider.get_scraper()
res = scraper.get('https://gitee.com/ngfchl/ptools/raw/master/update.md')
update_notes = markdown.markdown(res.text, extensions=['tables'])
# print(update_notes)
return render(request, 'auto_pt/restart.html', context={
'update_notes': update_notes
})

3528
db/pt.json Normal file

File diff suppressed because it is too large Load Diff

22
manage.py Executable file
View File

@@ -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', 'ptools.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()

4
pip.conf Normal file
View File

@@ -0,0 +1,4 @@
[global]
index-url = https://pypi.mirrors.ustc.edu.cn/simple/
trusted-host = mirrors.aliyun.com

912
pt_site/UtilityTool.py Normal file
View File

@@ -0,0 +1,912 @@
import logging
import re
import threading
from datetime import datetime
import aip
import cloudscraper
import opencc
import time
from django.db import transaction
from django.db.models import QuerySet
from lxml import etree
from pypushdeer import PushDeer
from requests import Response
from wechat_push import WechatPush
from wxpusher import WxPusher
from auto_pt.models import Notify, OCR
from pt_site.models import MySite, SignIn, TorrentInfo, SiteStatus
from ptools.base import TorrentBaseInfo, PushConfig, CommonResponse, StatusCodeEnum
def cookie2dict(source_str: str):
"""
cookies字符串转为字典格式,传入参数必须为cookies字符串
"""
dist_dict = {}
list_mid = source_str.split(';')
for i in list_mid:
# 以第一个选中的字符分割1次
list2 = i.split('=', 1)
dist_dict[list2[0]] = list2[1]
return dist_dict
# 获取字符串中的小数
get_decimals = lambda x: re.search("\d+(\.\d+)?", x).group()
converter = opencc.OpenCC('t2s.json')
lock = threading.Lock()
class FileSizeConvert:
"""文件大小和字节数互转"""
@staticmethod
def parse_2_byte(file_size: str):
"""将文件大小字符串解析为字节"""
regex = re.compile(r'(\d+(?:\.\d+)?)\s*([kmgtp]?b)', re.IGNORECASE)
order = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb']
for value, unit in regex.findall(file_size):
return int(float(value) * (1024 ** order.index(unit.lower())))
@staticmethod
def parse_2_file_size(byte: int):
units = ["B", "KB", "MB", "GB", "TB", "PB", 'EB']
size = 1024.0
for i in range(len(units)):
if (byte / size) < 1:
return "%.3f%s" % (byte, units[i])
byte = byte / size
class MessageTemplate:
"""消息模板"""
status_message_template = "等级:{} 魔力:{} 时魔:{} 积分:{} 分享率:{} 下载量:{} 上传量:{} 上传数:{} 下载数:{} 邀请:{} H&R{}\n"
class PtSpider:
"""爬虫"""
def __init__(self, browser='chrome', platform='darwin',
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko)',
*args, **kwargs):
self.browser = browser
self.platform = platform
self.headers = {
'User-Agent': user_agent,
# 'Connection': 'close',
# 'verify': 'false',
# 'keep_alive': 'False'
}
@staticmethod
def cookies2dict(source_str: str):
"""解析cookie"""
dist_dict = {}
list_mid = source_str.split(';')
for i in list_mid:
# 以第一个选中的字符分割1次
list2 = i.split('=', 1)
dist_dict[list2[0]] = list2[1]
return dist_dict
def get_scraper(self, delay=0):
return cloudscraper.create_scraper(browser={
'browser': self.browser,
'platform': self.platform,
'mobile': False
}, delay=delay)
def send_text(self, text: str, url: str = None):
"""通知分流"""
notifies = Notify.objects.filter(enable=True).all()
res = '你还没有配置通知参数哦!'
if len(notifies) <= 0:
return res
for notify in notifies:
if notify.name == PushConfig.wechat_work_push:
"""企业微信通知"""
notify_push = WechatPush(
corp_id=notify.corpid,
secret=notify.corpsecret,
agent_id=notify.agentid, )
res = notify_push.send_markdown(text)
print(res)
if notify.name == PushConfig.wxpusher_push:
"""WxPusher通知"""
res = WxPusher.send_message(
content=text,
url=url,
uids=notify.touser.split(','),
token=notify.corpsecret,
content_type=3, # 1文本2html3markdown
)
print(res)
if notify.name == PushConfig.pushdeer_push:
pushdeer = PushDeer(
server=notify.custom_server,
pushkey=notify.corpsecret)
# res = pushdeer.send_text(text, desp="optional description")
res = pushdeer.send_markdown(text)
print(res)
if notify.name == PushConfig.bark_push:
url = notify.custom_server + notify.corpsecret + '/' + text
res = self.get_scraper().get(url=url)
print(res)
def send_request(self,
my_site: MySite,
url: str,
method: str = 'get',
data: dict = None,
timeout: int = 20,
delay: int = 15,
proxies: dict = None):
site = my_site.site
scraper = self.get_scraper(delay=delay)
for k, v in eval(site.sign_in_headers).items():
self.headers[k] = v
# print(self.headers)
if method.lower() == 'post':
return scraper.post(
url=url,
headers=self.headers,
cookies=self.cookies2dict(my_site.cookie),
data=data,
timeout=timeout,
proxies=proxies
)
return scraper.get(
url=url,
headers=self.headers,
cookies=self.cookies2dict(my_site.cookie),
data=data,
timeout=timeout,
proxies=proxies,
)
def ocr_captcha(self, img_url):
"""百度OCR高精度识别传入图片URL"""
# 获取百度识别结果
ocr = OCR.objects.filter(enable=True).first()
if not ocr:
return CommonResponse.error(
status=StatusCodeEnum.OCR_NO_CONFIG,
)
try:
ocr_client = aip.AipOcr(appId=ocr.app_id, secretKey=ocr.secret_key, apiKey=ocr.api_key)
res1 = ocr_client.basicGeneralUrl(img_url)
print(res1)
if res1.get('error_code'):
res1 = ocr_client.basicAccurateUrl(img_url)
print('res1', res1)
if res1.get('error_code'):
return CommonResponse.error(
status=StatusCodeEnum.OCR_ACCESS_ERR,
msg=StatusCodeEnum.OCR_ACCESS_ERR.errmsg + res1.get('error_msg')
)
res2 = res1.get('words_result')[0].get('words')
# 去除杂乱字符
imagestring = ''.join(re.findall('[A-Za-z0-9]+', res2)).strip()
print('天空验证码:', imagestring, len(imagestring))
# 识别错误就重来
times = 0
while len(imagestring) != 6 and times <= 5:
print('验证码长度:', len(imagestring), len(imagestring) == 6)
time.sleep(1)
self.ocr_captcha(img_url)
times += 1
return CommonResponse.success(
status=StatusCodeEnum.OK,
data=imagestring,
)
except Exception as e:
print(str(e))
# raise
return CommonResponse.error(
status=StatusCodeEnum.OCR_ACCESS_ERR,
msg=StatusCodeEnum.OCR_ACCESS_ERR.errmsg + str(e)
)
""" paddleocr本地识别出问题暂时放弃
def paddle_ocr(self, img_src: str):
# paddle_ocr调用识别验证码,本地识别没有合适的结果再向百度OCR请求
paddle = PaddleOCR(use_angle_cls=True, lang='en')
try:
# result = paddle.ocr(img_src, cls=True)
result = paddle.ocr(img_src)
times = 0
print(result)
for line in result:
code = line[-1][0].strip()
print(code)
if len(code) != 6 and times <= 5:
times += 1
# print(times)
self.paddle_ocr(img_src)
# else:
if len(code) == 6:
return CommonResponse.success(
data=code
)
# 如果本地OCR失败就是用百度OCR
return self.ocr_captcha(img_url=img_src)
except Exception as e:
print(str(e))
return CommonResponse.error(msg='本地OCR识别失败' + str(e))
"""
def sign_in_hdsky(self, my_site: MySite, captcha=False):
"""HDSKY签到"""
site = my_site.site
url = site.url + site.page_sign_in.lstrip('/')
# sky无需验证码时使用本方案
if not captcha:
result = self.send_request(
my_site=my_site,
method=site.sign_in_method,
url=url,
data=eval(site.sign_in_params))
# sky无验证码方案结束
else:
# 获取img hash
print('# 开启验证码!')
res = self.send_request(
my_site=my_site,
method='post',
url=site.url + 'image_code_ajax.php',
data={
'action': 'new'
}).json()
# img url
img_get_url = site.url + 'image.php?action=regimage&imagehash=' + res.get('code')
print('验证码图片链接:', img_get_url)
# 获取OCR识别结果
# imagestring = self.ocr_captcha(img_url=img_get_url)
ocr_result = self.ocr_captcha(img_get_url)
if ocr_result.code == StatusCodeEnum.OK.code:
imagestring = ocr_result.data
else:
return ocr_result
# 组装请求参数
data = {
'action': 'showup',
'imagehash': res.get('code'),
'imagestring': imagestring
}
# print('请求参数', data)
result = self.send_request(
my_site=my_site,
method=site.sign_in_method,
url=url, data=data)
print('天空返回值:', result.content)
return CommonResponse.success(
status=StatusCodeEnum.OK,
data=result.json()
)
@staticmethod
def get_user_torrent(html, rule):
res_list = html.xpath(rule)
print('content', res_list)
print('res_list:', len(res_list))
return '0' if len(res_list) == 0 else res_list[0]
def do_sign_in(self, pool, queryset: QuerySet[MySite]):
message_list = '### <font color="orange">未显示的站点已经签到过了哟!</font> \n'
queryset = [my_site for my_site in queryset if
my_site.cookie and my_site.passkey and my_site.site.sign_in_support and my_site.signin_set.filter(
created_at__date__gte=datetime.today()).count() <= 0]
print(len(queryset))
if len(queryset) <= 0:
message_list += '> <font color="orange">已全部签到或无需签到!</font> \n'
# results = pool.map(pt_spider.sign_in, site_list)
with lock:
results = pool.map(self.sign_in, queryset)
for my_site, result in zip(queryset, results):
print('自动签到:', my_site, result)
if result.code == StatusCodeEnum.OK.code:
message_list += ('> ' + my_site.site.name + ' 签到成功!' + converter.convert(result.msg) + ' \n')
logging.info(my_site.site.name + '签到成功!' + result.msg)
else:
message = '> <font color="red">' + my_site.site.name + ' 签到失败!' + result.msg + '</font> \n'
message_list = message + message_list
logging.error(my_site.site.name + '签到失败!原因:' + result.msg)
return message_list
def sign_in(self, my_site: MySite):
"""签到"""
site = my_site.site
print(site.name + '开始签到')
signin_today = my_site.signin_set.filter(updated_at__date__gte=datetime.today()).first()
# 如果已有签到记录
if signin_today and signin_today.sign_in_today:
# pass
return CommonResponse.success(msg='已签到,请勿重复签到!')
else:
signin_today = SignIn(site=my_site)
url = site.url + site.page_sign_in.lstrip('/')
# print(url)
try:
# with lock:
if 'hdsky.me' in site.url:
result = self.sign_in_hdsky(my_site=my_site, captcha=site.sign_in_captcha)
if result.code == StatusCodeEnum.OK.code:
res_json = result.data
if res_json.get('success'):
# 签到成功
bonus = res_json.get('message')
days = (int(bonus) - 10) / 2 + 1
signin_today.sign_in_today = True
signin_today.save()
message = '成功,已连续签到{}天,魔力值加{},明日继续签到可获取{}魔力值!'.format(days, bonus, bonus + 2)
return CommonResponse.success(
status=StatusCodeEnum.OK,
msg=message
)
elif res_json.get('message') == 'invalid_imagehash':
# 验证码错误
return CommonResponse.error(
status=StatusCodeEnum.IMAGE_CODE_ERR,
)
elif res_json.get('message') == 'date_unmatch':
# 重复签到
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(
msg='今天已签到了哦!'
)
else:
# 签到失败
return CommonResponse.error(
status=StatusCodeEnum.FAILED_SIGN_IN,
)
if 'hdarea.co' in site.url:
res = self.send_request(my_site=my_site,
method=site.sign_in_method,
url=url,
data=eval(site.sign_in_params), )
if res.status_code == 200:
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(msg=res.text)
elif res.status_code == 503:
return CommonResponse.error(
status=StatusCodeEnum.WEB_CLOUD_FLARE,
)
else:
return CommonResponse.error(
status=StatusCodeEnum.WEB_CONNECT_ERR,
msg=StatusCodeEnum.WEB_CONNECT_ERR.errmsg + '签到失败!'
)
res = self.send_request(my_site=my_site, method=site.sign_in_method, url=url,
data=eval(site.sign_in_params))
if 'hares.top' in site.url:
print(res.text)
code = res.json().get('code')
print('白兔返回码:', type(code))
message = ''
if int(code) == 0:
"""
"datas": {
"id": 2273,
"uid": 2577,
"added": "2022-08-03 12:52:36",
"points": "200",
"total_points": 5435,
"days": 42,
"total_days": 123,
"added_time": "12:52:36",
"is_updated": 1
}
"""
message_template = '签到成功!奖励奶糖{},奶糖总奖励是{},您已连续签到{}天,签到总天数{}天,今日您的签到排名是{},'
data = res.json().get('datas')
message = message_template.format(data.get('points'),
data.get('total_points'),
data.get('days'),
data.get('total_days'))
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(msg=message)
elif int(code) == 1:
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(
msg=res.json().get('msg')
)
else:
return CommonResponse.error(
status=StatusCodeEnum.FAILED_SIGN_IN
)
if 'btschool' in site.url:
# print(res.content.decode('utf-8'))
text = self.parse(res, '//script/text()')
if len(text) > 0:
location = self.parse_school_location(text)
if 'addbouns.php' in location:
self.send_request(my_site=my_site, url=site.url + location.lstrip('/'))
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(msg='签到成功!')
else:
return CommonResponse.success(
msg='请勿重复签到!'
)
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(msg='签到成功!')
# print(res.text)
title_parse = self.parse(res, '//td[@id="outer"]//td[@class="embedded"]/h2/text()')
content_parse = self.parse(res, '//td[@id="outer"]//td[@class="embedded"]/table/tr/td//text()')
if len(content_parse) <= 0:
title_parse = self.parse(res, '//td[@id="outer"]//td[@class="embedded"]/b[1]/text()')
content_parse = self.parse(res, '//td[@id="outer"]//td[@class="embedded"]/text()[1]')
title = ''.join(title_parse).strip()
# print(content_parse)
content = ''.join(content_parse).strip().replace('\n', '')
# print(content)
message = title + ',' + content
# message = ''.join(title).strip()
signin_today.sign_in_today = True
signin_today.save()
return CommonResponse.success(msg=message)
except Exception as e:
raise
return CommonResponse.error(msg='签到失败!' + str(e))
@staticmethod
def parse(response, rules):
return etree.HTML(response.text).xpath(rules)
def send_torrent_info_request(self, my_site: MySite):
site = my_site.site
url = site.url + site.page_default.lstrip('/')
# print(url)
try:
response = self.send_request(my_site, url)
if response.status_code == 200:
return CommonResponse.success(data=response)
elif response.status_code == 503:
return CommonResponse.error(status=StatusCodeEnum.WEB_CLOUD_FLARE)
else:
return CommonResponse.error(msg="网站访问失败")
except Exception as e:
return CommonResponse.error(msg="网站访问失败" + str(e))
@transaction.atomic
def get_torrent_info_list(self, my_site: MySite, response: Response):
count = 0
new_count = 0
site = my_site.site
print(response)
try:
with lock:
if site.url == 'https://www.hd.ai/':
# print(response.text)
torrent_info_list = response.json().get('data').get('items')
print('海带首页种子数目', len(torrent_info_list))
for torrent_json_info in torrent_info_list:
# print(torrent_json_info.get('download'))
magnet_url = site.url + torrent_json_info.get('download')
sale_num = torrent_json_info.get('promotion_time_type')
# print(type(sale_status))
if sale_num == 1:
continue
# print(type(sale_num))
name = torrent_json_info.get('name')
title = torrent_json_info.get('small_descr')
download_url = site.url + torrent_json_info.get('download').lstrip('/')
result = TorrentInfo.objects.update_or_create(download_url=download_url, defaults={
'category': torrent_json_info.get('category'),
'site': site,
'name': name,
'title': title if title != '' else name,
'magnet_url': magnet_url,
'poster_url': torrent_json_info.get('poster'),
'detail_url': torrent_json_info.get('details'),
'sale_status': TorrentBaseInfo.sale_list.get(sale_num),
'sale_expire': torrent_json_info.get('promotion_until'),
'hr': True,
'on_release': torrent_json_info.get('added'),
'size': int(torrent_json_info.get('size')),
'seeders': torrent_json_info.get('seeders'),
'leechers': torrent_json_info.get('leechers'),
'completers': torrent_json_info.get('times_completed'),
'save_path': '/downloads/brush'
})
# print(result[0].site.url)
if not result[1]:
count += 1
else:
new_count += 1
# print(torrent_info)
else:
# response = self.send_request()
trs = self.parse(response, site.torrents_rule)
# print(response.text)
# print(trs)
# print(len(trs))
for tr in trs:
# print(tr)
# print(etree.tostring(tr))
sale_status = ''.join(tr.xpath(site.sale_rule))
# print('sale_status:', sale_status)
if not sale_status:
continue
sale_status = ''.join(re.split(r'[^\x00-\xff]', sale_status))
sale_status = sale_status.upper().replace('FREE', 'Free').replace(' ', '')
# # 下载链接,下载链接已存在则跳过
href = ''.join(tr.xpath(site.magnet_url_rule))
# print(href)
magnet_url = site.url + href.replace('&type=zip', '').replace(site.url, '')
if href.count('passkey') <= 0 and href.count('&sign=') <= 0:
download_url = magnet_url + '&passkey=' + my_site.passkey
else:
download_url = magnet_url
# print(download_url)
# print(magnet_url)
title_list = tr.xpath(site.title_rule)
print(title_list)
title = ''.join(title_list).strip().strip('剩余时间:').strip('剩餘時間:').strip('()')
# if sale_status == '2X':
# sale_status = '2XFree'
# # H&R 如果设置为不下载HR种子且种子HR为真,跳过
hr = True if ''.join(tr.xpath(site.hr_rule)) else False
# print(torrent.hr)
if hr and not site.hr:
continue
# # 促销到期时间
sale_expire = ''.join(tr.xpath(site.sale_expire_rule))
if site.url in [
'https://www.beitai.pt/',
'http://www.oshen.win/',
'https://www.hitpt.com/',
'https://hdsky.me/',
]:
"""
由于备胎等站优惠结束日期格式特殊,所以做特殊处理,使用正则表达式获取字符串中的时间
"""
sale_expire = ''.join(
re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', ''.join(sale_expire)))
# print(sale_expire)
# 如果促销结束时间为空,则为无限期
sale_expire = '无限期' if not sale_expire else sale_expire
# print(torrent_info.sale_expire)
# # 发布时间
on_release = ''.join(tr.xpath(site.release_rule))
# # 做种人数
seeders = ''.join(tr.xpath(site.seeders_rule))
# # # 下载人数
leechers = ''.join(tr.xpath(site.leechers_rule))
# # # 完成人数
completers = ''.join(tr.xpath(site.completers_rule))
# 存在则更新,不存在就创建
# print(type(seeders), type(leechers), type(completers), )
# print(seeders, leechers, completers)
# print(''.join(tr.xpath(site.name_rule)))
name = ''.join(tr.xpath(site.name_rule))
category = ''.join(tr.xpath(site.category_rule))
file_parse_size = ''.join(tr.xpath(site.size_rule))
# file_parse_size = ''.join(tr.xpath(''))
print(file_parse_size)
file_size = FileSizeConvert.parse_2_byte(file_parse_size)
# title = title if title else name
poster_url = ''.join(tr.xpath(site.poster_rule)) # 海报链接
detail_url = ''.join(tr.xpath(site.detail_url_rule))
print('name', site)
print('size', file_size, )
print('category', category, )
print('download_url', download_url, )
print('magnet_url', magnet_url, )
print('title', title, )
print('poster_url', poster_url, )
print('detail_url', detail_url, )
print('sale_status', sale_status, )
print('sale_expire', sale_expire, )
print('seeders', seeders, )
print('leechers', leechers)
print('H&R', hr)
print('completers', completers)
result = TorrentInfo.objects.update_or_create(download_url=download_url, defaults={
'category': category,
'magnet_url': magnet_url,
'site': site,
'name': name,
'title': title,
'poster_url': poster_url, # 海报链接
'detail_url': detail_url,
'sale_status': sale_status,
'sale_expire': sale_expire,
'hr': hr,
'on_release': on_release,
'size': file_size,
'seeders': seeders if seeders else '0',
'leechers': leechers if leechers else '0',
'completers': completers if completers else '0',
'save_path': '/downloads/brush'
})
print('拉取种子:', site.name, result[0])
# time.sleep(0.5)
if not result[1]:
count += 1
else:
new_count += 1
# print(torrent_info)
if count + new_count <= 0:
return CommonResponse.error(msg='抓取失败或无促销种子!')
return CommonResponse.success((new_count, count))
except Exception as e:
# raise
return CommonResponse.error(msg='解析种子页面失败!' + str(e))
# 从种子详情页面爬取种子HASH值
def get_hash(self, torrent_info: TorrentInfo):
site = torrent_info.site
url = site.url + torrent_info.detail_url
response = self.send_request(site.mysite, url)
# print(site, url, response.text)
# html = self.parse(response, site.hash_rule)
# has_string = self.parse(response, site.hash_rule)
# magnet_url = self.parse(response, site.magnet_url_rule)
hash_string = self.parse(response, '//tr[10]//td[@class="no_border_wide"][2]/text()')
magnet_url = self.parse(response, '//a[contains(@href,"downhash")]/@href')
torrent_info.hash_string = hash_string[0].replace('\xa0', '')
torrent_info.magnet_url = magnet_url[0]
print('种子HASH及下载链接', hash_string, magnet_url)
torrent_info.save()
# print(''.join(html))
# torrent_hash = html[0].strip('\xa0')
# TorrentInfo.objects.get(id=torrent_info.id).update(torrent_hash=torrent_hash)
# 生产者消费者模式测试
def send_status_request(self, my_site: MySite):
site = my_site.site
user_detail_url = site.url + site.page_user.lstrip('/').format(my_site.user_id)
print(user_detail_url)
# uploaded_detail_url = site.url + site.page_uploaded.lstrip('/').format(my_site.user_id)
seeding_detail_url = site.url + site.page_seeding.lstrip('/').format(my_site.user_id)
# completed_detail_url = site.url + site.page_completed.lstrip('/').format(my_site.user_id)
leeching_detail_url = site.url + site.page_leeching.lstrip('/').format(my_site.user_id)
try:
# 发送请求,做种信息与正在下载信息,个人主页
seeding_detail_res = self.send_request(my_site=my_site, url=seeding_detail_url, timeout=25)
# leeching_detail_res = self.send_request(my_site=my_site, url=leeching_detail_url, timeout=25)
user_detail_res = self.send_request(my_site=my_site, url=user_detail_url, timeout=25)
if seeding_detail_res.status_code != 200:
return CommonResponse.error(
status=StatusCodeEnum.WEB_CONNECT_ERR,
msg=site.name + '做种信息访问错误,错误码:' + str(seeding_detail_res.status_code)
)
# if leeching_detail_res.status_code != 200:
# return site.name + '种子下载信息获取错误,错误码:' + str(leeching_detail_res.status_code), False
if user_detail_res.status_code != 200:
return CommonResponse.error(
status=StatusCodeEnum.WEB_CONNECT_ERR,
msg=site.name + '个人主页访问错误,错误码:' + str(user_detail_res.status_code)
)
# print(user_detail_res.status_code)
# print('个人主页:', user_detail_res.content.decode('utf8'))
# 解析HTML
# print(user_detail_res.is_redirect)
details_html = etree.HTML(user_detail_res.content)
if 'school' in site.url:
text = details_html.xpath('//script/text()')
if len(text) > 0:
location = self.parse_school_location(text)
print('学校重定向链接:', location)
if '__SAKURA' in location:
res = self.send_request(my_site=my_site, url=site.url + location.lstrip('/'), timeout=25)
details_html = etree.HTML(res.text)
# print(res.content)
seeding_html = etree.HTML(seeding_detail_res.text)
# leeching_html = etree.HTML(leeching_detail_res.text)
# print(seeding_detail_res.content.decode('utf8'))
return CommonResponse.success(data={
'details_html': details_html,
'seeding_html': seeding_html,
# 'leeching_html': leeching_html
})
except Exception as e:
message = my_site.site.name + '访问个人主页信息:失败!原因:' + str(e)
logging.error(message)
# raise
return CommonResponse.error(msg=message)
@staticmethod
def parse_school_location(text: list):
print('解析学校访问链接', text)
list1 = [x.strip().strip('"') for x in text[0].split('+')]
list2 = ''.join(list1).split('=', 1)[1]
return list2.strip(';').strip('"')
def parse_status_html(self, my_site: MySite, result: dict):
"""解析个人状态"""
with lock:
site = my_site.site
details_html = result.get('details_html')
seeding_html = result.get('seeding_html')
# leeching_html = result.get('leeching_html')
# 获取指定元素
# title = details_html.xpath('//title/text()')
# seed_vol_list = seeding_html.xpath(site.record_bulk_rule)
seed_vol_list = seeding_html.xpath(site.seed_vol_rule)
if len(seed_vol_list) > 0:
seed_vol_list.pop(0)
# print('seeding_vol', seed_vol_list)
# 做种体积
seed_vol_all = 0
for seed_vol in seed_vol_list:
# print(etree.tostring(seed_vol))
vol = ''.join(seed_vol.xpath('.//text()'))
# print(vol)
if not len(vol) <= 0:
seed_vol_all += FileSizeConvert.parse_2_byte(vol)
else:
seed_vol_all = 0
print('做种体积:', FileSizeConvert.parse_2_file_size(seed_vol_all))
# print(''.join(seed_vol_list).strip().split(''))
# print(title)
# print(etree.tostring(details_html))
# leech = self.get_user_torrent(leeching_html, site.leech_rule)
# seed = self.get_user_torrent(seeding_html, site.seed_rule)
leech = ''.join(details_html.xpath(site.leech_rule)).strip()
# seed = ''.join(details_html.xpath(site.leech_rule)).strip()
seed = len(seed_vol_list)
ratio = ''.join(details_html.xpath(site.ratio_rule)).replace(',', '').strip(']:').strip()
if ratio == '无限' or ratio == '' or ratio == '---':
# inf表示无限
ratio = 'inf'
downloaded = ''.join(
details_html.xpath(site.downloaded_rule)
).replace(':', '').replace('\xa0\xa0', '').strip(' ')
uploaded = ''.join(
details_html.xpath(site.uploaded_rule)
).replace(':', '').strip(' ')
invitation = ''.join(
details_html.xpath(site.invitation_rule)
).strip(']:').replace('[', '').strip()
invitation = re.sub("\D", "", invitation)
# print('正则只保留数字', invitation)
# invitation = ''.join(
# details_html.xpath(site.invitation_rule)
# ).replace('[已签到]', '').replace('[签到]', '').strip(']:').replace('[', '').strip()
time_join_1 = ''.join(
details_html.xpath(site.time_join_rule)
).split('(')[0].strip('\xa0').strip()
# print('注册时间:', time_join_1)
time_join = time_join_1.replace('(', '').replace(')', '').strip('\xa0').strip()
if not my_site.time_join and time_join:
my_site.time_join = time_join
my_level_1 = ''.join(
details_html.xpath(site.my_level_rule)
).replace(' ', '').replace('(', '').replace(')', '').replace('_Name', '').strip()
# 去除字符串中的中文
if 'city' in site.url:
my_level = my_level_1.strip()
else:
my_level = re.sub(u"([^\u0041-\u005a\u0061-\u007a])", "", my_level_1)
# my_level = re.sub('[\u4e00-\u9fa5]', '', my_level_1)
# print('正则去除中文:', my_level)
latest_active_1 = ''.join(
details_html.xpath(site.latest_active_rule)
).split('(')[0].strip('\xa0').strip()
latest_active = latest_active_1.replace('(', '').replace(')', '').strip()
my_sp = ''.join(
details_html.xpath(site.my_sp_rule)
).replace(' ', '').replace('(', '').replace(')', '').replace(',', '').strip(']:').strip()
# 获取字符串中的魔力值
print('魔力:', details_html.xpath(site.my_sp_rule))
if my_sp:
my_sp = get_decimals(my_sp)
my_bonus_1 = ''.join(
details_html.xpath(site.my_bonus_rule)
).strip('N/A').replace(',', '').strip()
if my_bonus_1 != '':
my_bonus = get_decimals(my_bonus_1)
else:
my_bonus = 0
# if '' in my_bonus:
# my_bonus = my_bonus.split('')[0]
if site.url == 'https://www.pttime.org/':
my_sp = re.findall('-?\d+.?\d+', my_sp)[-1]
hr = ''.join(details_html.xpath(site.my_hr_rule)).split(' ')[0]
my_hr = hr if hr else '0'
# print(my_bonus)
# 更新我的站点数据
invitation = converter.convert(invitation)
invitation = re.sub('[\u4e00-\u9fa5]', '', invitation)
if invitation == '没有邀请资格':
invitation = 0
my_site.invitation = int(invitation) if invitation else 0
my_site.latest_active = latest_active if latest_active != '' else datetime.now()
my_site.my_level = my_level if my_level != '' else ' '
if my_hr:
my_site.my_hr = my_hr
my_site.seed = int(seed)
print(leech)
my_site.leech = int(leech)
print('站点:', site)
print('等级:', my_level, )
print('魔力:', my_sp, )
print('积分:', my_bonus if my_bonus else 0)
print('分享率:', ratio, )
print('下载量:', downloaded, )
print('上传量:', uploaded, )
print('邀请:', invitation, )
print('注册时间:', time_join, )
print('最后活动:', latest_active)
print('H&R', my_hr)
print('上传数:', seed)
print('下载数:', leech)
try:
res_sp_hour = self.get_hour_sp(my_site=my_site)
if not res_sp_hour[1]:
logging.error(my_site.site.name + '时魔获取失败!')
else:
my_site.sp_hour = res_sp_hour[0]
# 保存上传下载等信息
my_site.save()
# 外键反向查询
# status = my_site.sitestatus_set.filter(updated_at__date__gte=datetime.datetime.today())
# print(status)
result = SiteStatus.objects.update_or_create(site=my_site, updated_at__date__gte=datetime.today(),
defaults={
'ratio': float(ratio) if ratio else 0,
'downloaded': downloaded,
'uploaded': uploaded,
'my_sp': float(my_sp),
'my_bonus': float(my_bonus) if my_bonus != '' else 0,
# 做种体积
'seed_vol': seed_vol_all,
})
# print(result) # result 本身就是元祖
return CommonResponse.success(data=result)
except Exception as e:
message = my_site.site.name + '解析个人主页信息:失败!原因:' + str(e)
logging.error(message)
# raise
return CommonResponse.error(msg=message)
def get_hour_sp(self, my_site: MySite):
"""获取时魔"""
site = my_site.site
response = self.send_request(
my_site=my_site,
url=site.url + site.page_mybonus,
)
res = converter.convert(response.content)
print('时魔响应', response.status_code)
# print('转为简体的时魔页面:', str(res))
# res_list = self.parse(res, site.hour_sp_rule)
res_list = etree.HTML(res).xpath(site.hour_sp_rule)
print('时魔字符串', res_list)
if len(res_list) <= 0:
return '时魔获取失败!', False
return get_decimals(res_list[0]), True

1
pt_site/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'pt_site.apps.PtSiteConfig'

816
pt_site/admin.py Normal file
View File

@@ -0,0 +1,816 @@
from datetime import datetime
import qbittorrentapi
import time
import transmission_rpc
from django.contrib import admin, messages
from django.db import transaction
from django.http import JsonResponse
from django.utils.html import format_html
from import_export.admin import ImportExportModelAdmin
from simpleui.admin import AjaxAdmin
from transmission_rpc import Torrent
from pt_site.UtilityTool import MessageTemplate, FileSizeConvert
from pt_site.models import Site, Downloader, SignIn
from pt_site.models import TorrentInfo, SiteStatus, MySite
# Register your models here.
from pt_site.views import pool, pt_spider
from ptools.base import StatusCodeEnum
admin.site.site_header = 'PT一下你就晓嘚'
admin.site.site_title = 'PT一下你就晓嘚'
admin.site.index_title = '我在后台首页'
@admin.register(Site)
class SiteAdmin(ImportExportModelAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = (
'name',
'custom_url',
'sign_in_support',
'get_userinfo_support',
'get_torrent_support',
'search_support',
'created_at',
'updated_at',
# 'remark',
)
actions_selection_counter = True
# list_display_links = None
# 过滤字段
list_filter = ('name',
'sign_in_support',
'get_userinfo_support',
'get_torrent_support',
'search_support',)
# 搜索
search_fields = ('name',)
list_editable = ('sign_in_support',
'get_userinfo_support',
'get_torrent_support',
'search_support')
# def has_delete_permission(self, request, obj=None):
# return False
# def has_add_permission(self, request, obj=None):
# return False
# def has_change_permission(self, request, obj=None):
# return False
# 自定义样式
# def custom_date(self, obj):
# return format_html(
# '<span style="color: red;">{}</span>',
# obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
# )
# 自定义样式
def custom_url(self, obj):
return format_html(
'<a style="color: red;" target="blank" href="{}">{}</span>',
obj.url + obj.page_default,
obj.url
)
def get_search_results(self, request, queryset, search_term):
queryset, use_distinct = super(SiteAdmin, self).get_search_results(request, queryset, search_term)
if not request.META['PATH_INFO'] == '/admin/pt_site/site/':
queryset = queryset.exclude(
pk__in=[site.pk for site in Site.objects.all() if
MySite.objects.filter(site=site).count() >= 1])
return queryset, use_distinct
custom_url.short_description = '站点网址'
formfield_overrides = {}
# 分组设置编辑字段
fieldsets = (
['站点设置', {
# 'classes': ('collapse',), # CSS
'fields': (
('name', 'url', 'logo'),
('limit_speed', 'sp_full',),
),
}],
['功能支持', {
# 'classes': ('collapse',), # CSS
'fields': (
('sign_in_support', 'get_userinfo_support',),
('get_torrent_support', 'search_support',),
),
}],
['签到设置', {
'classes': ('collapse',), # CSS
'fields': (
('sign_in_captcha', 'sign_in_method',),
('sign_in_headers', 'sign_in_params',)
),
}],
['站点主要页面', {
'classes': ('collapse',), # CSS
'fields': (
'page_default',
'page_sign_in',
'page_detail',
'page_download',
'page_user',
'page_search',
'page_leeching',
'page_uploaded',
'page_seeding',
'page_completed',
'page_mybonus',
'page_viewfilelist',
'page_viewpeerlist',
),
}],
['H&R设置', {
'classes': ('collapse',), # CSS
'fields': (
'hr',
('hr_rate', 'hr_time',)
),
}],
['站点信息规则', {
'classes': ('collapse',), # CSS
'fields': (
'invitation_rule',
'time_join_rule',
'latest_active_rule',
'ratio_rule',
'uploaded_rule',
'downloaded_rule',
'seed_vol_rule',
'my_level_rule',
'my_sp_rule',
'hour_sp_rule',
'my_bonus_rule',
'my_hr_rule',
'seed_rule',
'leech_rule',
'mailbox_rule',
),
}],
['种子获取规则', {
'classes': ('collapse',), # CSS
'fields': (
'torrents_rule',
'name_rule',
'title_rule',
'detail_url_rule',
'category_rule',
'poster_rule',
'download_url_rule',
'magnet_url_rule',
'size_rule',
'hr_rule',
'sale_rule',
'sale_expire_rule',
'release_rule',
'seeders_rule',
'leechers_rule',
'completers_rule',
'record_count_rule',
'hash_rule',
'peer_speed_rule',
'viewpeerlist_rule',
'viewfilelist_rule',
),
}]
)
class StatusInlines(admin.TabularInline):
model = SiteStatus
fields = [
'uploaded', 'downloaded', 'ratio',
'my_sp', 'my_bonus', 'seed_vol',
'updated_at'
]
readonly_fields = ['updated_at']
ordering = ['-updated_at']
# 自定义模板,删除外键显示
template = 'admin/pt_site/inline_status/tabular.html'
# 禁止添加按钮
def has_add_permission(self, request, obj=None):
return False
# 禁止删除按钮
def has_delete_permission(self, request, obj=None):
return False
# 禁止修改按钮
def has_change_permission(self, request, obj=None):
return False
@admin.register(MySite)
class MySiteAdmin(ImportExportModelAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = (
'sort_id',
'user_id',
'site',
'sign_in_state',
# 'sign_in_today',
'invitation',
'my_level',
'my_hr',
'leech',
'seed',
'sp_hour',
# 'publish',
# 'latest_active',
'time_join',
'status_today',
)
autocomplete_fields = ('site',)
search_fields = ('site',)
list_display_links = ['user_id']
list_editable = ['sort_id']
ordering = ('sort_id',)
# empty_value_display = '**'
inlines = (
StatusInlines,
)
# 自定义更新时间,提醒今日是否更新
def status_today(self, obj: MySite):
is_update = obj.updated_at.date() == datetime.today().date()
return format_html('<img src="/static/admin/img/icon-{}.svg">{}',
'yes' if is_update and obj.site.get_userinfo_support else 'no',
obj.updated_at, )
status_today.short_description = '更新时间'
# 签到过滤
class SignInFilter(admin.SimpleListFilter):
title = '今日签到' # 过滤标题显示为"以 英雄性别"
parameter_name = 'sign_in_state' # 过滤器使用的过滤字段
def lookups(self, request, model_admin):
return (
(False, '未签到'),
(True, '已签到'),
)
def queryset(self, request, queryset):
# print(queryset)
signin_list = SignIn.objects.filter(
created_at__date__gte=datetime.today(),
sign_in_today=True
).all()
# 已签到
pk_signin_list = [signin.site.pk for signin in signin_list]
# 加入无需签到
pk_signin_list.extend([my_site.pk for my_site in queryset if not my_site.site.sign_in_support])
# print(type(self.value()))
if self.value() is None:
return queryset
if bool(self.value()):
return queryset.exclude(pk__in=pk_signin_list)
if not bool(self.value()):
return queryset.filter(pk__in=pk_signin_list)
# 过滤未抓取个人数据站点
class UpdatedAtFilter(admin.SimpleListFilter):
title = '今日刷新' # 过滤标题显示为"以 英雄性别"
parameter_name = 'status_today' # 过滤器使用的过滤字段
def lookups(self, request, model_admin):
return (
(0, '未刷新'),
(1, '已刷新'),
)
def queryset(self, request, queryset):
update_list = MySite.objects.filter(updated_at__date__gte=datetime.today())
if self.value() is None:
return queryset
if int(self.value()) == 0:
return queryset.exclude(pk__in=update_list)
if int(self.value()) == 1:
return queryset.filter(pk__in=update_list)
list_filter = (SignInFilter, UpdatedAtFilter, 'my_level')
def sign_in_state(self, obj: MySite):
signin_today = obj.signin_set.filter(created_at__date__gte=datetime.today()).first()
if not obj.site.sign_in_support:
return format_html('<a href="#">无需</a>')
else:
return format_html('<img src="/static/admin/img/icon-{}.svg">',
'yes' if signin_today and signin_today.sign_in_today else 'no')
sign_in_state.short_description = '今日签到'
# def get_changeform_initial_data(self, request):
# print(request)
# return super(MySiteAdmin, self).get_changeform_initial_data(request)
# 过滤字段
# list_filter = ('site', 'support')
# 顶部显示按钮
actions = ['sign_in', 'get_status', 'get_torrents', 'sign_in_celery']
# 底部显示按钮
actions_on_bottom = True
def sign_in(self, request, queryset):
start = time.time()
queryset = [my_site for my_site in queryset if
my_site.cookie and my_site.passkey and my_site.site.sign_in_support and my_site.signin_set.filter(
created_at__date__gte=datetime.today()).count() <= 0]
if len(queryset) <= 0:
messages.add_message(request, messages.SUCCESS, '已签到或无需签到!')
results = pool.map(pt_spider.sign_in, queryset)
for my_site, result in zip(queryset, results):
print(my_site, result.code)
if result.code == StatusCodeEnum.OK.code:
messages.add_message(request, messages.SUCCESS, my_site.site.name + '' + result.msg)
# elif result[0] == 503:
# messages.add_message(request, messages.ERROR, my_site.site.name + '签到失败原因5秒盾起作用了别试了')
else:
messages.add_message(request, messages.ERROR, my_site.site.name + '签到失败!原因:' + result.msg)
end = time.time()
print('耗时:', end - start)
# 显示的文本与django admin一致
sign_in.short_description = '签到'
# icon参考element-ui icon与https://fontawesome.com
sign_in.icon = 'el-icon-star-on'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
sign_in.type = 'success'
# 获取站点个人数据
@transaction.atomic
def get_status(self, request, queryset):
start = time.time()
# info_list = SiteStatus.objects.filter(update_date=datetime.now().date())
site_list = [my_site for my_site in queryset if my_site.site.get_userinfo_support]
results = pool.map(pt_spider.send_status_request, site_list)
message_template = MessageTemplate.status_message_template
for my_site, result in zip(site_list, results):
if result.code == StatusCodeEnum.OK.code:
res = pt_spider.parse_status_html(my_site, result.data)
# print(my_site.site, result)
site_status = res.data[0]
if isinstance(site_status, SiteStatus):
message = my_site.site.name + '{}'.format('信息获取成功!' if res.data[1] else '信息更新成功!')
# status = my_site.sitestatus_set.filter(created_at__date__gte=datetime.today()).first()
# print(status.ratio)
message += message_template.format(
my_site.my_level,
site_status.my_sp,
my_site.sp_hour,
site_status.my_bonus,
site_status.ratio,
site_status.downloaded,
site_status.uploaded,
my_site.seed,
my_site.leech,
my_site.invitation,
my_site.my_hr
)
messages.add_message(
request,
messages.SUCCESS,
message=message)
else:
messages.add_message(
request,
messages.ERROR,
my_site.site.name + '信息更新失败!原因:' + res.msg)
else:
messages.add_message(
request,
messages.ERROR,
my_site.site.name + '信息更新失败!原因:' + result.msg)
end = time.time()
print('耗时:', end - start)
get_status.short_description = '更新数据'
# icon参考element-ui icon与https://fontawesome.com
get_status.icon = 'el-icon-refresh'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
get_status.type = 'primary'
# 拉取种子
def get_torrents(self, request, queryset):
start = time.time()
site_list = [my_site for my_site in queryset if my_site.site.get_torrent_support]
results = pool.map(pt_spider.send_torrent_info_request, site_list)
for my_site, result in zip(site_list, results):
# print(result is tuple[int])
if result.code == StatusCodeEnum.OK.code:
# print(my_site.site, result[0].content.decode('utf8'))
res = pt_spider.get_torrent_info_list(my_site, result.data)
if res.code == StatusCodeEnum.OK.code:
messages.add_message(
request,
messages.SUCCESS,
'{} 种子抓取成功!新增种子{}条,更新种子{}条:'.format(my_site.site.name, res.data[0], res.data[1])
)
else:
messages.add_message(
request,
messages.SUCCESS if res[1] else messages.ERROR,
my_site.site.name + '抓取种子信息失败!原因:' + res.msg
)
else:
messages.add_message(request, messages.ERROR,
my_site.site.name + '抓取种子信息失败!原因:' + result.msg)
end = time.time()
print('耗时:', end - start)
# 显示的文本与django admin一致
get_torrents.short_description = '拉取促销种子'
# icon参考element-ui icon与https://fontawesome.com
get_torrents.icon = 'el-icon-download'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
get_torrents.type = 'warning'
fieldsets = (
['用户信息', {
'fields': (
('site',),
('user_id', 'passkey',),
'cookie',
# 'time_join'
),
}],
['用户设置', {
'fields': (
('sign_in', 'hr',),
('search',),
),
}],
)
@admin.register(SiteStatus)
class SiteStatusAdmin(ImportExportModelAdmin):
# formats = (base_formats.XLS, base_formats.CSV)
list_display = ['site',
# 'sign_in', 'my_level', 'invitation', 'seed', 'leech',
'uploaded', 'downloaded', 'ratio',
'my_sp', 'my_bonus',
# 'my_hr', 'time_join', 'latest_active',
'updated_at']
list_filter = ['site', 'updated_at']
list_display_links = None
ordering = ['site__sort_id']
autocomplete_fields = ('site',)
# 禁止添加按钮
def has_add_permission(self, request):
return False
# 禁止删除按钮
def has_delete_permission(self, request, obj=None):
return False
# 禁止修改按钮
def has_change_permission(self, request, obj=None):
return False
# def changelist_view(self, request, extra_context=None):
# default_filter = False
# try:
# ref = request.META['HTTP_REFERER']
# pinfo = request.META['PATH_INFO']
# qstr = ref.split(pinfo)
# # request.META['QUERY_STRING'] = 'update_date=' + str(datetime.now().date())
# # print(request.GET, len(qstr))
# # print(pinfo, qstr, ref)
# # print(qstr[1].split('='))
# # 没有参数时使用默认过滤器
# if len(qstr[1].split('=')) <= 1:
# default_filter = True
# # print(request.META['QUERY_STRING'])
# except:
# default_filter = True
# if default_filter:
# q = request.GET.copy()
# # 添加查询参数,默认为只查询当天数据
# q['updated_at'] = str(datetime.now().date())
# # print(q)
# request.GET = q
# # print(request.GET)
# request.META['QUERY_STRING'] = request.GET.urlencode()
# # print(request.META)
#
# return super(SiteStatusAdmin, self).changelist_view(request, extra_context=extra_context)
@admin.register(Downloader)
class DownloaderAdmin(ImportExportModelAdmin, AjaxAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = ('name', 'category', 'reserved_space', 'created_at', 'updated_at')
# 过滤字段
list_filter = ('name', 'category')
# 搜索
search_fields = ('name', 'category')
# 增加自定义按钮
actions = ['test_button']
def save_model(self, request, obj, form, change):
obj.save()
self.test_connect(request, obj)
def test_button(self, request, queryset):
for downloader in queryset:
self.test_connect(request, downloader)
# 连接测试
@staticmethod
def test_connect(request, downloader):
try:
conn = False
# if downloader.category == 'Tr':
# tr_client = transmission_rpc.Client(host=downloader.host, port=downloader.port,
# username=downloader.username, password=downloader.password)
# # print(tr_client.port_test())
# # return True, ''
# conn = True
if downloader.category == 'Qb':
qb_client = qbittorrentapi.Client(host=downloader.host, port=downloader.port,
username=downloader.username, password=downloader.password)
qb_client.auth_log_in()
# return qb_client.is_logged_in, ''
conn = qb_client.is_logged_in
# if downloader.category == 'De':
# de_client = deluge_client.DelugeRPCClient(host=downloader.host, port=downloader.port,
# username=downloader.username, password=downloader.password)
# de_client.connect()
# # return de_client.connected, ''
# conn = de_client.connected
if conn:
messages.add_message(request, messages.SUCCESS, downloader.name + '连接成功!')
except Exception as e:
# print(e)
messages.add_message(
request,
messages.ERROR,
downloader.name + '连接失败!请确认下载器信息填写正确:' + str(e) # 输出异常
)
# return False, str(e)
# 显示的文本与django admin一致
test_button.short_description = '测试连接'
# icon参考element-ui icon与https://fontawesome.com
test_button.icon = 'fas fa-audio-description'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
test_button.type = 'success'
# 给按钮追加自定义的颜色
# test_button.style = 'color:white;'
# 模型保存后的操作
# @receiver(post_save, sender=Downloader)
# def post_save_downloader(sender, **kwargs):
# print(kwargs['signal'].__attr__)
# print(sender.test_connect(kwargs['instance']))
@admin.register(TorrentInfo)
class TorrentInfoAdmin(ImportExportModelAdmin, AjaxAdmin): # instead of ModelAdmin
# formats = (base_formats.XLS, base_formats.CSV)
# 显示字段
list_display = (
'name_href',
'title_href',
'site',
'state',
'hr',
# 'category',
'file_size',
'sale_status',
'seeders',
'leechers',
'completers',
'downloader',
'd_progress',
# 'add_a', # 增加种子链接按钮
'sale_expire',
# 'updated_at'
)
# list_display_links = None
def file_size(self, torrent_info: TorrentInfo):
return FileSizeConvert.parse_2_file_size(torrent_info.size)
file_size.short_description = '文件大小'
# 过滤字段
list_filter = ('site', 'title', 'category', 'sale_status',)
# 搜索
search_fields = ('name', 'category')
# 分页
list_per_page = 50
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
# 自定义样式
def add_a(self, obj):
return format_html(
# <el-link href="{}" target="_blank">下载种子</el-link>
# '<a target="blank" href="{}" >下载种子</a>',
'<a href="{}" target="_blank">下载种子</a>',
obj.magnet_url
)
def name_href(self, obj: TorrentInfo):
return format_html(
# <el-link href="{}" target="_blank">下载种子</el-link>
# '<a target="blank" href="{}" >下载种子</a>',
'<a href="{}" target="_blank" title="{}">{}</a>',
obj.magnet_url,
obj.name,
obj.name[0:25] + ' ...'
)
def title_href(self, obj: TorrentInfo):
return format_html(
# <el-link href="{}" target="_blank">下载种子</el-link>
# '<a target="blank" href="{}" >下载种子</a>',
'<a href="{}" target="_blank" title="{}">{}</a>',
obj.site.url + obj.detail_url,
obj.title,
obj.title[0:20] + ' ...'
)
def d_progress(self, obj: TorrentInfo):
if not obj.downloader:
return 0
tr_client = transmission_rpc.Client(
host=obj.downloader.host,
port=obj.downloader.port,
username=obj.downloader.username,
password=obj.downloader.password
)
torrent = tr_client.get_torrent(obj.hash_string)
progress = torrent.progress
print(progress)
speed = round(torrent.rateDownload / 1024 / 1024, 2)
if progress < 100:
return format_html('<a href="#" target="_blank">{}</a>', str(speed) + 'MB/s')
return format_html('<a href="#" target="_blank">{}</a>', str(torrent.progress) + '%')
# name_href.short_description = '种子名称'
name_href.short_description = format_html(
"""
<a href="#">{}</a>
""", '种子名称'
)
title_href.short_description = '标题'
d_progress.short_description = '下载进度'
add_a.short_description = '下载链接'
# 增加自定义按钮
actions = ['to_download', 'update_state']
# 列表推导式来获取下载器
# downloader_list = [{'key': i.id, 'label': i.name} for i in Downloader.objects.all()]
def update_state(self, request, queryset):
for obj in queryset:
tr_client = transmission_rpc.Client(
host=obj.downloader.host,
port=obj.downloader.port,
username=obj.downloader.username,
password=obj.downloader.password
)
torrent = tr_client.get_torrent(int(obj.hash))
print(round(torrent.rateDownload / 1024 / 1024, 2))
def to_download(self, request, queryset):
# 这里的queryset 会有数据过滤,只包含选中的数据
post = request.POST
downloader = Downloader.objects.get(id=post.get('downloader'))
# print(downloader)
# 这里获取到数据后,可以做些业务处理
# post中的_action 是方法名
# post中 _selected 是选中的数据,逗号分割
if not post.get('_selected'):
return JsonResponse(data={
'status': 'error',
'msg': '请先选中数据!'
})
else:
try:
# qbittorrentrpc_core.Torrent_management.add()
# downloader = Downloader.objects.get(id=post.get('downloader'))
# c = Client(host='192.168.123.2', port=9091, username='ngfchl', password='.wq891222')
# qb_client.auth_log_in()
# print(qb_client.torrents)
tr_client = transmission_rpc.Client(host=downloader.host,
port=downloader.port,
username=downloader.username,
password=downloader.password)
# 判断剩余空间大小,小于预留空间则停止推送种子
if tr_client.free_space('/downloads') <= downloader.reserved_space * 1024 * 1024 * 1024:
return JsonResponse(data={
'status': 'error',
'msg': downloader.name + '磁盘空间已不足,请及时清理!'
})
# torrent_list = [i.magnet_url for i in queryset]
for torrent_info in queryset:
if not torrent_info.hash_string:
pt_spider.get_hash(torrent_info=torrent_info)
# print(qb_client.torrent_categories.categories.get(torrent.category))
print(torrent_info.magnet_url)
# res = qb_client.torrents_add(torrent.magnet_url)
res = tr_client.add_torrent(torrent=torrent_info.magnet_url,
download_dir=torrent_info.save_path)
print(res)
if isinstance(res, Torrent):
torrent_info.hash = res.id
torrent_info.state = True
torrent_info.downloader = downloader
torrent_info.save()
return JsonResponse(data={
'status': 'success',
'msg': torrent_info.name + '推送成功!'
})
else:
return JsonResponse(data={
'status': 'error',
'msg': torrent_info.name + '推送失败!'
})
except Exception as e:
# raise
return JsonResponse(data={
'status': 'error',
'msg': str(e) + ''
})
# 显示的文本与django admin一致
to_download.short_description = '推送到下载器'
update_state.short_description = '更新种子'
# icon参考element-ui icon与https://fontawesome.com
to_download.icon = 'el-icon-upload'
update_state.icon = 'el-icon-refresh'
# 指定element-ui的按钮类型参考https://element.eleme.cn/#/zh-CN/component/button
to_download.type = 'warning'
update_state.type = 'success'
# 给按钮追加自定义的颜色
# test_button.style = 'color:white;'
# 这里的layer配置是动态的可以根据需求返回不同的配置
# 这里的queryset 或根据搜索条件来过滤数据
# def async_get_layer_config(self, request, queryset):
# """
# 这个方法只有一个request参数没有其他的入参
# """
# # 模拟处理业务耗时
# time.sleep(2)
# 可以根据request的用户来动态设置返回哪些字段每次点击都会来获取配置显示
to_download.layer = {
# 弹出层中的输入框配置
# 这里指定对话框的标题
'title': '推送到下载器',
# 提示信息
'tips': '异步获取配置' + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# 确认按钮显示文本
'confirm_button': '确认提交',
# 取消按钮显示文本
'cancel_button': '取消',
# 弹出层对话框的宽度默认50%
'width': '40%',
# 表单中 label的宽度对应element-ui的 label-width默认80px
'labelWidth': "80px",
'params': [{
'type': 'select',
'key': 'downloader',
'label': '类型',
'width': '200px',
# size对应elementui的size取值为medium / small / mini
'size': 'small',
# value字段可以指定默认值
'value': '',
# 列表推导式来获取下载器
# 'options': [{'key': i.id, 'label': i.name} for i in Downloader.objects.all()]
}]
}

7
pt_site/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PtSiteConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pt_site'
verbose_name = 'PT站点管理'

View File

@@ -0,0 +1,867 @@
# Generated by Django 4.1 on 2022-08-24 15:08
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Downloader",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
("name", models.CharField(max_length=12, verbose_name="名称")),
(
"category",
models.CharField(
choices=[("Qb", "qBittorrent")],
default="Qb",
max_length=128,
verbose_name="下载器",
),
),
("username", models.CharField(max_length=16, verbose_name="用户名")),
("password", models.CharField(max_length=128, verbose_name="密码")),
("host", models.CharField(max_length=32, verbose_name="HOST")),
(
"port",
models.IntegerField(
default=8999,
validators=[
django.core.validators.MaxValueValidator(65535),
django.core.validators.MinValueValidator(1001),
],
verbose_name="端口",
),
),
(
"reserved_space",
models.IntegerField(
default=30,
help_text="单位GB最小为1G最大512G",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(512),
],
verbose_name="预留磁盘空间",
),
),
],
options={"verbose_name": "下载器", "verbose_name_plural": "下载器",},
),
migrations.CreateModel(
name="MySite",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
("sort_id", models.IntegerField(default=1, verbose_name="排序")),
("user_id", models.CharField(max_length=16, verbose_name="用户ID")),
("passkey", models.CharField(max_length=128, verbose_name="PassKey")),
("cookie", models.TextField(verbose_name="COOKIE")),
(
"hr",
models.BooleanField(
default=False, help_text="是否下载HR种子", verbose_name="开启HR下载"
),
),
(
"sign_in",
models.BooleanField(
default=True, help_text="是否开启签到", verbose_name="开启签到"
),
),
(
"search",
models.BooleanField(
default=True, help_text="是否开启搜索", verbose_name="开启搜索"
),
),
("invitation", models.IntegerField(default=0, verbose_name="邀请资格")),
(
"time_join",
models.DateTimeField(blank=True, null=True, verbose_name="注册时间"),
),
(
"latest_active",
models.DateTimeField(blank=True, null=True, verbose_name="最近活动时间"),
),
(
"sp_hour",
models.CharField(default="", max_length=8, verbose_name="时魔"),
),
(
"my_level",
models.CharField(default="", max_length=16, verbose_name="用户等级"),
),
(
"my_hr",
models.CharField(default="", max_length=16, verbose_name="H&R"),
),
("leech", models.IntegerField(default=0, verbose_name="当前下载")),
("seed", models.IntegerField(default=0, verbose_name="当前做种")),
("mail", models.IntegerField(default=0, verbose_name="新邮件")),
("publish", models.IntegerField(default=0, verbose_name="发布种子")),
],
options={"verbose_name": "我的站点", "verbose_name_plural": "我的站点",},
),
migrations.CreateModel(
name="Site",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
("name", models.CharField(max_length=32, verbose_name="站点名称")),
(
"url",
models.URLField(
default="", help_text='请保留网址结尾的"/"', verbose_name="站点网址"
),
),
(
"logo",
models.URLField(
default="", help_text="站点logo图标", verbose_name="站点logo"
),
),
(
"sign_in_support",
models.BooleanField(default=True, verbose_name="签到支持"),
),
(
"get_torrent_support",
models.BooleanField(default=True, verbose_name="拉取首页种子"),
),
(
"get_userinfo_support",
models.BooleanField(default=True, verbose_name="获取个人数据"),
),
(
"search_support",
models.BooleanField(default=False, verbose_name="搜索支持"),
),
(
"page_default",
models.CharField(
default="torrents.php", max_length=64, verbose_name="默认搜索页面"
),
),
(
"page_sign_in",
models.CharField(
default="attendance.php", max_length=64, verbose_name="默认签到链接"
),
),
(
"page_detail",
models.CharField(
default="details.php?id={}",
max_length=64,
verbose_name="详情页面链接",
),
),
(
"page_download",
models.CharField(
default="download.php?id={}",
max_length=64,
verbose_name="默认下载链接",
),
),
(
"page_user",
models.CharField(
default="userdetails.php?id={}",
max_length=64,
verbose_name="用户信息链接",
),
),
(
"page_search",
models.CharField(
default="torrents.php?search={}",
max_length=64,
verbose_name="搜索链接",
),
),
(
"page_leeching",
models.CharField(
default="getusertorrentlistajax.php?userid={}&type=leeching",
max_length=64,
verbose_name="当前下载信息",
),
),
(
"page_uploaded",
models.CharField(
default="getusertorrentlistajax.php?userid={}&type=uploaded",
max_length=64,
verbose_name="发布种子信息",
),
),
(
"page_seeding",
models.CharField(
default="getusertorrentlistajax.php?userid={}&type=seeding",
max_length=64,
verbose_name="当前做种信息",
),
),
(
"page_completed",
models.CharField(
default="getusertorrentlistajax.php?userid={}&type=completed",
max_length=64,
verbose_name="完成种子信息",
),
),
(
"page_mybonus",
models.CharField(
default="mybonus.php", max_length=64, verbose_name="魔力值页面"
),
),
(
"page_viewfilelist",
models.CharField(
default="viewfilelist.php?id={}",
max_length=64,
verbose_name="文件列表链接",
),
),
(
"page_viewpeerlist",
models.CharField(
default="viewpeerlist.php?id={}",
max_length=64,
verbose_name="当前用户列表",
),
),
(
"sign_in_method",
models.CharField(
default="get",
help_text="get或post请使用小写字母默认get",
max_length=5,
verbose_name="签到请求方法",
),
),
(
"sign_in_captcha",
models.BooleanField(
default=False, help_text="有签到验证码的站点请开启", verbose_name="签到验证码"
),
),
(
"sign_in_params",
models.CharField(
blank=True,
default="{}",
help_text="默认无参数",
max_length=128,
null=True,
verbose_name="签到请求参数",
),
),
(
"sign_in_headers",
models.CharField(
default="{}",
help_text='字典格式:{"accept":"application/json","c":"d"},默认无参数',
max_length=128,
verbose_name="签到请求头",
),
),
(
"hr",
models.BooleanField(
default=False, help_text="站点是否开启HR", verbose_name="H&R"
),
),
(
"hr_rate",
models.IntegerField(
default=2, help_text="站点要求HR种子的分享率最小1", verbose_name="HR分享率"
),
),
(
"hr_time",
models.IntegerField(
default=10,
help_text="站点要求HR种子最短做种时间单位小时",
verbose_name="HR时间",
),
),
(
"sp_full",
models.FloatField(default=0, help_text="时魔满魔", verbose_name="满魔"),
),
(
"limit_speed",
models.IntegerField(
default=100,
help_text="站点盒子限速家宽用户无需理会单位MB/S",
verbose_name="上传速度限制",
),
),
(
"torrents_rule",
models.CharField(
default='//table[@class="torrents"]/tr',
max_length=128,
verbose_name="种子行信息",
),
),
(
"name_rule",
models.CharField(
default='.//td[@class="embedded"]/a/b/text()',
max_length=128,
verbose_name="种子名称",
),
),
(
"title_rule",
models.CharField(
default=".//tr/td[1]/text()",
max_length=128,
verbose_name="种子标题",
),
),
(
"detail_url_rule",
models.CharField(
default='.//td[@class="embedded"]/a[contains(@href,"detail")]/@href',
max_length=128,
verbose_name="种子详情",
),
),
(
"category_rule",
models.CharField(
default='.//td[@class="rowfollow nowrap"][1]/a[1]/img/@class',
max_length=128,
verbose_name="分类",
),
),
(
"poster_rule",
models.CharField(
default=".//table/tr/td[1]/img/@src",
max_length=128,
verbose_name="海报",
),
),
(
"magnet_url_rule",
models.CharField(
default='.//td/a[contains(@href,"download")]/@href',
max_length=128,
verbose_name="下载链接",
),
),
(
"download_url_rule",
models.CharField(
default='.//a[contains(@href,"download.php?id=") and contains(@href,"passkey")]/@href',
max_length=128,
verbose_name="种子链接",
),
),
(
"size_rule",
models.CharField(
default=".//td[5]/text()", max_length=128, verbose_name="文件大小"
),
),
(
"hr_rule",
models.CharField(
default='.//table/tr/td/img[@class="hitandrun"]/@title',
max_length=128,
verbose_name="H&R",
),
),
(
"sale_rule",
models.CharField(
default='.//table/tr/td/img[contains(@class,"pro_")]/@alt',
max_length=128,
verbose_name="促销信息",
),
),
(
"sale_expire_rule",
models.CharField(
default=".//table/tr/td/font/span/@title",
max_length=128,
verbose_name="促销时间",
),
),
(
"release_rule",
models.CharField(
default=".//td[4]/span/@title",
max_length=128,
verbose_name="发布时间",
),
),
(
"seeders_rule",
models.CharField(
default=".//td[6]/b/a/text()",
max_length=128,
verbose_name="做种人数",
),
),
(
"leechers_rule",
models.CharField(
default=".//td[7]/b/a/text()",
max_length=128,
verbose_name="下载人数",
),
),
(
"completers_rule",
models.CharField(
default=".//td[8]/a/b/text()",
max_length=128,
verbose_name="完成人数",
),
),
(
"viewfilelist_rule",
models.CharField(
default=".//td/text()", max_length=128, verbose_name="解析文件结构"
),
),
(
"viewpeerlist_rule",
models.CharField(
default=".//tr/td[9]/nobr/text()",
max_length=128,
verbose_name="平均下载进度",
),
),
(
"peer_speed_rule",
models.CharField(
default=".//tr/td[5]/nobr/text()",
max_length=128,
verbose_name="平均上传速度",
),
),
(
"remark",
models.TextField(
blank=True, default="", null=True, verbose_name="备注"
),
),
(
"invitation_rule",
models.CharField(
default='//a[contains(@href,"invite.php?id=")]/following-sibling::text()[1]',
max_length=128,
verbose_name="邀请资格",
),
),
(
"time_join_rule",
models.CharField(
default='//td[contains(text(),"加入")]/following-sibling::td/span/@title',
max_length=128,
verbose_name="注册时间",
),
),
(
"latest_active_rule",
models.CharField(
default='//td[contains(text(),"最近动向")]/following-sibling::td/span/@title',
max_length=128,
verbose_name="最后活动时间",
),
),
(
"uploaded_rule",
models.CharField(
default='//font[@class="color_uploaded"]/following-sibling::text()[1]',
max_length=128,
verbose_name="上传量",
),
),
(
"downloaded_rule",
models.CharField(
default='//font[@class="color_downloaded"]/following-sibling::text()[1]',
max_length=128,
verbose_name="下载量",
),
),
(
"ratio_rule",
models.CharField(
default='//font[@class="color_ratio"][1]/following-sibling::text()[1]',
max_length=128,
verbose_name="分享率",
),
),
(
"my_sp_rule",
models.CharField(
default='//a[@href="mybonus.php"]/following-sibling::text()[1]',
max_length=128,
verbose_name="魔力值",
),
),
(
"hour_sp_rule",
models.CharField(
default='//div[contains(text(),"每小时能获取")]/text()[1]',
max_length=128,
verbose_name="时魔",
),
),
(
"my_bonus_rule",
models.CharField(
default='//font[@class="color_bonus" and contains(text(),"积分")]/following-sibling::text()[1]',
max_length=128,
verbose_name="保种积分",
),
),
(
"my_level_rule",
models.CharField(
default='//span[@class="medium"]/span[@class="nowrap"]/a[contains(@class,"_Name")]/@class',
max_length=128,
verbose_name="用户等级",
),
),
(
"my_hr_rule",
models.CharField(
default="//tr[14]/td[2]/a/text()",
max_length=128,
verbose_name="H&R",
),
),
(
"leech_rule",
models.CharField(
default='//img[@class="arrowdown"]/following-sibling::text()[1]',
max_length=128,
verbose_name="下载数量",
),
),
(
"seed_rule",
models.CharField(
default='//img[@class="arrowup"]/following-sibling::text()[1]',
max_length=128,
verbose_name="做种数量",
),
),
(
"record_count_rule",
models.CharField(
default="/html/body/b/text()",
max_length=128,
verbose_name="种子记录数",
),
),
(
"seed_vol_rule",
models.CharField(
default="//tr/td[3]",
help_text="需对数据做处理",
max_length=128,
verbose_name="做种大小",
),
),
(
"mailbox_rule",
models.CharField(
default='//a[@href="messages.php"]/following-sibling::text()[1]',
help_text="获取新邮件",
max_length=128,
verbose_name="邮件规则",
),
),
(
"hash_rule",
models.CharField(
default='//tr[11]//td[@class="no_border_wide"][2]/text()',
max_length=128,
verbose_name="种子HASH",
),
),
],
options={
"verbose_name": "站点信息",
"verbose_name_plural": "站点信息",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="TorrentInfo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"name",
models.CharField(default="", max_length=256, verbose_name="种子名称"),
),
(
"title",
models.CharField(default="", max_length=256, verbose_name="标题"),
),
(
"category",
models.CharField(default="", max_length=128, verbose_name="分类"),
),
(
"poster_url",
models.URLField(default="", max_length=512, verbose_name="海报链接"),
),
(
"detail_url",
models.URLField(default="", max_length=512, verbose_name="种子详情"),
),
("magnet_url", models.URLField(verbose_name="下载链接")),
(
"download_url",
models.URLField(max_length=255, unique=True, verbose_name="种子链接"),
),
("size", models.IntegerField(default=0, verbose_name="文件大小")),
(
"state",
models.BooleanField(
default=False, max_length=16, verbose_name="推送状态"
),
),
(
"save_path",
models.FilePathField(
default="/downloads/brush", verbose_name="保存路径"
),
),
("hr", models.BooleanField(default=False, verbose_name="H&R")),
(
"sale_status",
models.CharField(default="无促销", max_length=16, verbose_name="优惠状态"),
),
(
"sale_expire",
models.CharField(default="无限期", max_length=32, verbose_name="到期时间"),
),
(
"on_release",
models.CharField(default="", max_length=32, verbose_name="发布时间"),
),
(
"seeders",
models.CharField(default="0", max_length=8, verbose_name="做种人数"),
),
(
"leechers",
models.CharField(default="0", max_length=8, verbose_name="下载人数"),
),
(
"completers",
models.CharField(default="0", max_length=8, verbose_name="完成人数"),
),
(
"hash_string",
models.CharField(
default="", max_length=128, verbose_name="Info_hash"
),
),
(
"viewfilelist",
models.CharField(default="", max_length=128, verbose_name="文件列表"),
),
(
"viewpeerlist",
models.FloatField(default=0, max_length=128, verbose_name="下载总进度"),
),
(
"peer_list_speed",
models.FloatField(default=0, max_length=128, verbose_name="平均上传速度"),
),
(
"downloader",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pt_site.downloader",
verbose_name="下载器",
),
),
(
"site",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pt_site.site",
verbose_name="所属站点",
),
),
],
options={"verbose_name": "种子管理", "verbose_name_plural": "种子管理",},
),
migrations.CreateModel(
name="SiteStatus",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"uploaded",
models.CharField(default="0", max_length=16, verbose_name="上传量"),
),
(
"downloaded",
models.CharField(default="0", max_length=16, verbose_name="下载量"),
),
("ratio", models.FloatField(default=0, verbose_name="分享率")),
("my_sp", models.FloatField(default=0, verbose_name="魔力值")),
("my_bonus", models.FloatField(default=0, verbose_name="做种积分")),
("seed_vol", models.IntegerField(default=0, verbose_name="做种体积")),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pt_site.mysite",
verbose_name="站点名称",
),
),
],
options={"verbose_name": "我的数据", "verbose_name_plural": "我的数据",},
),
migrations.CreateModel(
name="SignIn",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
(
"sign_in_today",
models.BooleanField(default=False, verbose_name="签到"),
),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pt_site.mysite",
verbose_name="站点名称",
),
),
],
options={"verbose_name": "签到", "verbose_name_plural": "签到",},
),
migrations.AddField(
model_name="mysite",
name="site",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="pt_site.site",
verbose_name="站点",
),
),
]

View File

357
pt_site/models.py Normal file
View File

@@ -0,0 +1,357 @@
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from ptools.base import BaseEntity, DownloaderCategory
# Create your models here.
# 支持的站点
class Site(BaseEntity):
# 站点设置
name = models.CharField(max_length=32, verbose_name='站点名称')
url = models.URLField(verbose_name='站点网址', default='', help_text='请保留网址结尾的"/"')
logo = models.URLField(verbose_name='站点logo', default='', help_text='站点logo图标')
# 功能支持
sign_in_support = models.BooleanField(verbose_name="签到支持", default=True)
get_torrent_support = models.BooleanField(verbose_name="拉取首页种子", default=True)
get_userinfo_support = models.BooleanField(verbose_name="获取个人数据", default=True)
search_support = models.BooleanField(verbose_name="搜索支持", default=False)
# 主要页面
page_default = models.CharField(verbose_name='默认搜索页面', default='torrents.php', max_length=64)
page_sign_in = models.CharField(verbose_name='默认签到链接', default='attendance.php', max_length=64)
page_detail = models.CharField(verbose_name='详情页面链接', default='details.php?id={}', max_length=64)
page_download = models.CharField(verbose_name='默认下载链接', default='download.php?id={}', max_length=64)
page_user = models.CharField(verbose_name='用户信息链接', default='userdetails.php?id={}', max_length=64)
page_search = models.CharField(verbose_name='搜索链接', default='torrents.php?search={}', max_length=64)
page_leeching = models.CharField(verbose_name='当前下载信息',
default='getusertorrentlistajax.php?userid={}&type=leeching',
max_length=64)
page_uploaded = models.CharField(verbose_name='发布种子信息',
default='getusertorrentlistajax.php?userid={}&type=uploaded',
max_length=64)
page_seeding = models.CharField(verbose_name='当前做种信息',
default='getusertorrentlistajax.php?userid={}&type=seeding',
max_length=64)
page_completed = models.CharField(verbose_name='完成种子信息',
default='getusertorrentlistajax.php?userid={}&type=completed',
max_length=64)
page_mybonus = models.CharField(verbose_name='魔力值页面',
default='mybonus.php',
max_length=64)
page_viewfilelist = models.CharField(verbose_name='文件列表链接',
default='viewfilelist.php?id={}',
max_length=64)
page_viewpeerlist = models.CharField(verbose_name='当前用户列表',
default='viewpeerlist.php?id={}',
max_length=64)
sign_in_method = models.CharField(verbose_name='签到请求方法',
default='get',
help_text='get或post请使用小写字母默认get',
max_length=5)
sign_in_captcha = models.BooleanField(verbose_name='签到验证码',
default=False,
help_text='有签到验证码的站点请开启', )
sign_in_params = models.CharField(verbose_name='签到请求参数',
default='{}',
help_text='默认无参数',
max_length=128,
blank=True,
null=True)
sign_in_headers = models.CharField(verbose_name='签到请求头',
default='{}',
help_text='字典格式:{"accept":"application/json","c":"d"},默认无参数',
max_length=128)
# HR及其他
hr = models.BooleanField(verbose_name='H&R', default=False, help_text='站点是否开启HR')
hr_rate = models.IntegerField(verbose_name='HR分享率', default=2, help_text='站点要求HR种子的分享率最小1')
hr_time = models.IntegerField(verbose_name='HR时间', default=10, help_text='站点要求HR种子最短做种时间单位小时')
sp_full = models.FloatField(verbose_name='满魔', default=0, help_text='时魔满魔')
limit_speed = models.IntegerField(verbose_name='上传速度限制',
default=100,
help_text='站点盒子限速家宽用户无需理会单位MB/S')
# xpath规则
torrents_rule = models.CharField(verbose_name='种子行信息',
default='//table[@class="torrents"]/tr',
max_length=128)
name_rule = models.CharField(verbose_name='种子名称',
default='.//td[@class="embedded"]/a/b/text()',
max_length=128)
title_rule = models.CharField(verbose_name='种子标题',
default='.//tr/td[1]/text()',
max_length=128)
detail_url_rule = models.CharField(
verbose_name='种子详情',
default='.//td[@class="embedded"]/a[contains(@href,"detail")]/@href',
max_length=128)
category_rule = models.CharField(
verbose_name='分类',
default='.//td[@class="rowfollow nowrap"][1]/a[1]/img/@class',
max_length=128)
poster_rule = models.CharField(
verbose_name='海报',
default='.//table/tr/td[1]/img/@src',
max_length=128)
magnet_url_rule = models.CharField(
verbose_name='下载链接',
default='.//td/a[contains(@href,"download")]/@href',
max_length=128)
download_url_rule = models.CharField(
verbose_name='种子链接',
default='.//a[contains(@href,"download.php?id=") and contains(@href,"passkey")]/@href',
max_length=128)
size_rule = models.CharField(verbose_name='文件大小',
default='.//td[5]/text()',
max_length=128)
hr_rule = models.CharField(
verbose_name='H&R',
default='.//table/tr/td/img[@class="hitandrun"]/@title',
max_length=128)
sale_rule = models.CharField(
verbose_name='促销信息',
default='.//table/tr/td/img[contains(@class,"pro_")]/@alt',
max_length=128
)
sale_expire_rule = models.CharField(
verbose_name='促销时间',
default='.//table/tr/td/font/span/@title',
max_length=128)
release_rule = models.CharField(
verbose_name='发布时间',
default='.//td[4]/span/@title',
max_length=128)
seeders_rule = models.CharField(
verbose_name='做种人数',
default='.//td[6]/b/a/text()',
max_length=128)
leechers_rule = models.CharField(
verbose_name='下载人数',
default='.//td[7]/b/a/text()',
max_length=128)
completers_rule = models.CharField(
verbose_name='完成人数',
default='.//td[8]/a/b/text()',
max_length=128)
viewfilelist_rule = models.CharField(
verbose_name='解析文件结构',
default='.//td/text()',
max_length=128)
viewpeerlist_rule = models.CharField(
verbose_name='平均下载进度',
default='.//tr/td[9]/nobr/text()',
max_length=128)
peer_speed_rule = models.CharField(
verbose_name='平均上传速度',
default='.//tr/td[5]/nobr/text()',
max_length=128)
remark = models.TextField(verbose_name='备注', default='', null=True, blank=True)
# 状态信息XPath
invitation_rule = models.CharField(
verbose_name='邀请资格',
default='//a[contains(@href,"invite.php?id=")]/following-sibling::text()[1]',
max_length=128)
time_join_rule = models.CharField(
verbose_name='注册时间',
default='//td[contains(text(),"加入")]/following-sibling::td/span/@title',
max_length=128)
latest_active_rule = models.CharField(
verbose_name='最后活动时间',
default='//td[contains(text(),"最近动向")]/following-sibling::td/span/@title',
max_length=128)
uploaded_rule = models.CharField(
verbose_name='上传量',
default='//font[@class="color_uploaded"]/following-sibling::text()[1]',
max_length=128)
downloaded_rule = models.CharField(
verbose_name='下载量',
default='//font[@class="color_downloaded"]/following-sibling::text()[1]',
max_length=128)
ratio_rule = models.CharField(
verbose_name='分享率',
default='//font[@class="color_ratio"][1]/following-sibling::text()[1]',
max_length=128)
my_sp_rule = models.CharField(
verbose_name='魔力值',
default='//a[@href="mybonus.php"]/following-sibling::text()[1]',
max_length=128)
hour_sp_rule = models.CharField(
verbose_name='时魔',
default='//div[contains(text(),"每小时能获取")]/text()[1]',
max_length=128)
my_bonus_rule = models.CharField(
verbose_name='保种积分',
default='//font[@class="color_bonus" and contains(text(),"积分")]/following-sibling::text()[1]',
max_length=128)
my_level_rule = models.CharField(
verbose_name='用户等级',
default='//span[@class="medium"]/span[@class="nowrap"]/a[contains(@class,"_Name")]/@class',
max_length=128
)
my_hr_rule = models.CharField(
verbose_name='H&R',
default='//tr[14]/td[2]/a/text()',
max_length=128)
leech_rule = models.CharField(
verbose_name='下载数量',
default='//img[@class="arrowdown"]/following-sibling::text()[1]',
max_length=128)
seed_rule = models.CharField(verbose_name='做种数量',
default='//img[@class="arrowup"]/following-sibling::text()[1]',
max_length=128)
record_count_rule = models.CharField(verbose_name='种子记录数',
default='/html/body/b/text()',
max_length=128)
seed_vol_rule = models.CharField(verbose_name='做种大小',
default='//tr/td[3]',
help_text='需对数据做处理',
max_length=128)
mailbox_rule = models.CharField(verbose_name='邮件规则',
default='//a[@href="messages.php"]/following-sibling::text()[1]',
help_text='获取新邮件',
max_length=128)
# HASH RULE
hash_rule = models.CharField(verbose_name='种子HASH',
default='//tr[11]//td[@class="no_border_wide"][2]/text()',
max_length=128)
class Meta:
verbose_name = '站点信息'
verbose_name_plural = verbose_name
ordering = ['name', ]
def __str__(self):
return self.name
class MySite(BaseEntity):
site = models.OneToOneField(verbose_name='站点', to=Site, on_delete=models.CASCADE)
sort_id = models.IntegerField(verbose_name='排序', default=1)
# 用户信息
user_id = models.CharField(verbose_name='用户ID', max_length=16)
passkey = models.CharField(max_length=128, verbose_name='PassKey')
cookie = models.TextField(verbose_name='COOKIE')
# 用户设置
hr = models.BooleanField(verbose_name='开启HR下载', default=False, help_text='是否下载HR种子')
sign_in = models.BooleanField(verbose_name='开启签到', default=True, help_text='是否开启签到')
search = models.BooleanField(verbose_name='开启搜索', default=True, help_text='是否开启搜索')
# 用户数据 自动拉取
invitation = models.IntegerField(verbose_name='邀请资格', default=0)
time_join = models.DateTimeField(verbose_name='注册时间', blank=True, null=True)
latest_active = models.DateTimeField(verbose_name='最近活动时间', blank=True, null=True)
sp_hour = models.CharField(verbose_name='时魔', max_length=8, default='')
my_level = models.CharField(verbose_name='用户等级', max_length=16, default='')
my_hr = models.CharField(verbose_name='H&R', max_length=16, default='')
leech = models.IntegerField(verbose_name='当前下载', default=0)
seed = models.IntegerField(verbose_name='当前做种', default=0)
mail = models.IntegerField(verbose_name='新邮件', default=0)
publish = models.IntegerField(verbose_name='发布种子', default=0)
def __str__(self):
return self.site.name
class Meta:
verbose_name = '我的站点'
verbose_name_plural = verbose_name
# 站点信息
class SiteStatus(BaseEntity):
# 获取日期,只保留当天最新数据
site = models.ForeignKey(verbose_name='站点名称', to=MySite, on_delete=models.CASCADE)
# 签到,有签到功能的访问签到页面,无签到的访问个人主页
uploaded = models.CharField(verbose_name='上传量', default='0', max_length=16)
downloaded = models.CharField(verbose_name='下载量', default='0', max_length=16)
ratio = models.FloatField(verbose_name='分享率', default=0)
my_sp = models.FloatField(verbose_name='魔力值', default=0)
my_bonus = models.FloatField(verbose_name='做种积分', default=0)
seed_vol = models.IntegerField(verbose_name='做种体积', default=0)
class Meta:
verbose_name = '我的数据'
verbose_name_plural = verbose_name
def __str__(self):
return self.site.site.name
class SignIn(BaseEntity):
site = models.ForeignKey(verbose_name='站点名称', to=MySite, on_delete=models.CASCADE)
sign_in_today = models.BooleanField(verbose_name='签到', default=False)
class Meta:
verbose_name = '签到'
verbose_name_plural = verbose_name
def __str__(self):
return self.site.site.name
class Downloader(BaseEntity):
# 下载器名称
name = models.CharField(max_length=12, verbose_name='名称')
# 下载器类别 tr qb de
category = models.CharField(max_length=128, choices=DownloaderCategory.choices,
default=DownloaderCategory.qBittorrent,
verbose_name='下载器')
# 用户名
username = models.CharField(max_length=16, verbose_name='用户名')
# 密码
password = models.CharField(max_length=128, verbose_name='密码')
# host
host = models.CharField(max_length=32, verbose_name='HOST')
# port
port = models.IntegerField(default=8999, verbose_name='端口', validators=[
MaxValueValidator(65535),
MinValueValidator(1001)
])
# 预留空间
reserved_space = models.IntegerField(default=30, verbose_name='预留磁盘空间', validators=[
MinValueValidator(1),
MaxValueValidator(512)
], help_text='单位GB最小为1G最大512G')
class Meta:
verbose_name = '下载器'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
# 种子信息
class TorrentInfo(BaseEntity):
site = models.ForeignKey(to=Site, on_delete=models.CASCADE, verbose_name='所属站点', null=True)
name = models.CharField(max_length=256, verbose_name='种子名称', default='')
title = models.CharField(max_length=256, verbose_name='标题', default='')
category = models.CharField(max_length=128, verbose_name='分类', default='')
poster_url = models.URLField(max_length=512, verbose_name='海报链接', default='')
detail_url = models.URLField(max_length=512, verbose_name='种子详情', default='')
magnet_url = models.URLField(verbose_name='下载链接')
download_url = models.URLField(verbose_name='种子链接', unique=True, max_length=255)
size = models.IntegerField(verbose_name='文件大小', default=0)
state = models.BooleanField(max_length=16, verbose_name='推送状态', default=False)
save_path = models.FilePathField(verbose_name='保存路径', default='/downloads/brush')
hr = models.BooleanField(verbose_name='H&R', default=False)
sale_status = models.CharField(verbose_name='优惠状态', default='无促销', max_length=16)
sale_expire = models.CharField(verbose_name='到期时间', default='无限期', max_length=32)
on_release = models.CharField(verbose_name='发布时间', default='', max_length=32)
seeders = models.CharField(verbose_name='做种人数', default='0', max_length=8)
leechers = models.CharField(verbose_name='下载人数', default='0', max_length=8)
completers = models.CharField(verbose_name='完成人数', default='0', max_length=8)
downloader = models.ForeignKey(to=Downloader,
on_delete=models.CASCADE,
verbose_name='下载器',
blank=True, null=True)
hash_string = models.CharField(max_length=128, verbose_name='Info_hash', default='')
viewfilelist = models.CharField(max_length=128, verbose_name='文件列表', default='')
viewpeerlist = models.FloatField(max_length=128, verbose_name='下载总进度', default=0)
peer_list_speed = models.FloatField(max_length=128, verbose_name='平均上传速度', default=0)
class Meta:
verbose_name = '种子管理'
verbose_name_plural = verbose_name
def __str__(self):
return self.name

3
pt_site/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

15
pt_site/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path
from . import views
from .views import scheduler
status = scheduler.state
urlpatterns = [
path(r'auto_sign_in', views.auto_sign_in, name='auto_sign_in'),
path(r'auto_get_status', views.auto_get_status, name='auto_get_status'),
path(r'auto_update_torrents', views.auto_update_torrents, name='auto_update_torrents'),
path(r'auto_remove_expire_torrents', views.auto_remove_expire_torrents, name='auto_remove_expire_torrents'),
path(r'auto_push_to_downloader', views.auto_push_to_downloader, name='auto_push_to_downloader'),
path(r'auto_get_torrent_hash', views.auto_get_torrent_hash, name='auto_get_torrent_hash'),
]

165
pt_site/views.py Normal file
View File

@@ -0,0 +1,165 @@
# Create your views here.
import logging
from concurrent.futures.thread import ThreadPoolExecutor
import time
from apscheduler.schedulers.background import BackgroundScheduler
from django_apscheduler.jobstores import DjangoJobStore
from pt_site.UtilityTool import PtSpider, MessageTemplate
from pt_site.models import MySite
from ptools.base import StatusCodeEnum
job_defaults = {
'coalesce': True,
'misfire_grace_time': None
}
executors = {
'default': ThreadPoolExecutor(2)
}
scheduler = BackgroundScheduler(timezone='Asia/Shanghai')
scheduler.add_jobstore(DjangoJobStore(), 'default')
pool = ThreadPoolExecutor(2)
pt_spider = PtSpider()
# Create your views here.
try:
def auto_sign_in():
"""自动签到"""
start = time.time()
# 获取本人所有站点
queryset = MySite.objects.all()
message_list = pt_spider.do_sign_in(pool, queryset)
end = time.time()
consuming = '> <font color="blue">{} 任务运行成功!耗时:{}完成时间:{} </font>\n'.format(
'自动签到', end - start,
time.strftime("%Y-%m-%d %H:%M:%S")
)
pt_spider.send_text(message_list + consuming)
print('{} 任务运行成功!完成时间:{}'.format('自动签到', time.strftime("%Y-%m-%d %H:%M:%S")))
def auto_get_status():
"""
更新个人数据
"""
start = time.time()
message_list = ''
queryset = MySite.objects.all()
site_list = [my_site for my_site in queryset if my_site.site.get_userinfo_support]
results = pool.map(pt_spider.send_status_request, site_list)
message_template = MessageTemplate.status_message_template
for my_site, result in zip(site_list, results):
if result.code == StatusCodeEnum.OK.code:
res = pt_spider.parse_status_html(my_site, result.data)
print('自动更新个人数据', my_site.site, res)
if res.code == StatusCodeEnum.OK.code:
message = message_template.format(
my_site.my_level,
res.data[0].my_sp,
my_site.sp_hour,
res.data[0].my_bonus,
res.data[0].ratio,
res.data[0].downloaded,
res.data[0].uploaded,
my_site.seed,
my_site.leech,
my_site.invitation,
my_site.my_hr
)
print('组装Message', message)
message_list += ('> ' + my_site.site.name + ' 信息更新成功!' + message + ' \n')
# pt_spider.send_text(my_site.site.name + ' 信息更新成功!' + message)
logging.info(my_site.site.name + '信息更新成功!' + message)
else:
print(res)
message = '> <font color="red">' + my_site.site.name + ' 信息更新失败!原因:' + res.msg + '</font> \n'
message_list = message + message_list
# pt_spider.send_text(my_site.site.name + ' 信息更新失败!原因:' + str(res[0]))
logging.error(my_site.site.name + '信息更新失败!原因:' + res.msg)
else:
# pt_spider.send_text(my_site.site.name + ' 信息更新失败!原因:' + str(result[1]))
message = '> <font color="red">' + my_site.site.name + ' 信息更新失败!原因:' + result.msg + '</font> \n'
message_list = message + message_list
logging.error(my_site.site.name + '信息更新失败!原因:' + result.msg)
end = time.time()
consuming = '> <font color="blue">{} 任务运行成功!耗时:{} 完成时间:{} </font> \n'.format(
'自动更新个人数据', end - start,
time.strftime("%Y-%m-%d %H:%M:%S")
)
pt_spider.send_text(message_list + consuming)
def auto_update_torrents():
"""
拉取最新种子
"""
start = time.time()
message_list = ''
queryset = MySite.objects.all()
site_list = [my_site for my_site in queryset if my_site.site.get_torrent_support]
results = pool.map(pt_spider.send_torrent_info_request, site_list)
for my_site, result in zip(site_list, results):
print('获取种子:', my_site.site, result)
# print(result is tuple[int])
if result.code == StatusCodeEnum.OK.code:
res = pt_spider.get_torrent_info_list(my_site, result.data)
# 通知推送
if res.code == StatusCodeEnum.OK.code:
message = '> {} 种子抓取成功!新增种子{}条,更新种子{}条! \n'.format(my_site.site.name, res.data[0], res.data[1])
message_list += message
else:
message = '> <font color="red">' + my_site.site.name + '抓取种子信息失败!原因:' + res.msg + '</font> \n'
message_list = message + message_list
# 日志
logging.info(
'{} 种子抓取成功!新增种子{}条,更新种子{}条! '.format(my_site.site.name, res.data[0], res.data[
1]) if res.code == StatusCodeEnum.OK.code else my_site.site.name + '抓取种子信息失败!原因:' + res.msg)
else:
# pt_spider.send_text(my_site.site.name + ' 抓取种子信息失败!原因:' + result[0])
message = '> <font color="red">' + my_site.site.name + ' 抓取种子信息失败!原因:' + result.msg + '</font> \n'
message_list = message + message_list
logging.error(my_site.site.name + '抓取种子信息失败!原因:' + result.msg)
end = time.time()
consuming = '> {} 任务运行成功!耗时:{} 当前时间:{} \n'.format(
'拉取最新种子',
end - start,
time.strftime("%Y-%m-%d %H:%M:%S"))
pt_spider.send_text(message_list + consuming)
def auto_remove_expire_torrents():
"""
删除过期种子
"""
start = time.time()
end = time.time()
pt_spider.send_text(
'> {} 任务运行成功!耗时:{}{} \n'.format('签到', end - start, time.strftime("%Y-%m-%d %H:%M:%S")))
def auto_push_to_downloader():
"""推送到下载器"""
start = time.time()
print('推送到下载器')
end = time.time()
pt_spider.send_text(
'> {} 任务运行成功!耗时:{}{} \n'.format('签到', end - start, time.strftime("%Y-%m-%d %H:%M:%S")))
def auto_get_torrent_hash():
"""自动获取种子HASH"""
start = time.time()
print('自动获取种子HASH')
time.sleep(5)
end = time.time()
pt_spider.send_text(
'> {} 任务运行成功!耗时:{}{} \n'.format('获取种子HASH', end - start, time.strftime("%Y-%m-%d %H:%M:%S")))
scheduler.start()
except Exception as e:
print(e)
# 有错误就停止定时器
scheduler.shutdown()

2
ptools/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.

16
ptools/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for djangoProject 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/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ptools.settings')
application = get_asgi_application()

149
ptools/base.py Normal file
View File

@@ -0,0 +1,149 @@
from enum import Enum
from django.db import models
# 基类
class BaseEntity(models.Model):
created_at = models.DateTimeField(verbose_name='创建时间', auto_now_add=True)
updated_at = models.DateTimeField(verbose_name='更新时间', auto_now=True)
class Meta:
abstract = True
class StatusCodeEnum(Enum):
"""状态码枚举类"""
OK = (0, '成功')
ERROR = (-1, '错误')
SERVER_ERR = (500, '服务器异常')
# OCR 1
OCR_NO_CONFIG = (1001, 'OCR未配置')
OCR_ACCESS_ERR = (1002, '在线OCR接口访问错误')
# 签到
SUCCESS_SIGN_IN = (2000, '签到成功!')
FAILED_SIGN_IN = (2001, '签到失败!')
IS_SIGN_IN = (2002, '请勿重复签到!')
ALL_SIGN_IN = (2003, '已全部签到哦!')
# 验证码 4
IMAGE_CODE_ERR = (4001, '验证码错误(Wrong CAPTCHA)')
THROTTLING_ERR = (4002, '访问过于频繁')
# 网络
WEB_CONNECT_ERR = (4404, '网站访问错误!')
WEB_CLOUD_FLARE = (4503, '我遇到CF盾咯')
NECESSARY_PARAM_ERR = (4003, '缺少必传参数')
USER_ERR = (4004, '用户名错误')
PWD_ERR = (4005, '密码错误')
CPWD_ERR = (4006, '密码不一致')
MOBILE_ERR = (4007, '手机号错误')
SMS_CODE_ERR = (4008, '短信验证码有误')
ALLOW_ERR = (4009, '未勾选协议')
SESSION_ERR = (4010, '用户未登录')
DB_ERR = (5000, '数据错误')
EMAIL_ERR = (5001, '邮箱错误')
TEL_ERR = (5002, '固定电话错误')
NODATA_ERR = (5003, '无数据')
NEW_PWD_ERR = (5004, '新密码错误')
OPENID_ERR = (5005, '无效的openid')
PARAM_ERR = (5006, '参数错误')
STOCK_ERR = (5007, '库存不足')
@property
def code(self):
"""获取状态码"""
return self.value[0]
@property
def errmsg(self):
"""获取状态码信息"""
return self.value[1]
class CommonResponse:
"""
统一的json返回格式
"""
def __init__(self, data, status: StatusCodeEnum, msg):
self.data = data
self.code = status.code
if msg is None:
self.msg = status.errmsg
else:
self.msg = msg
@classmethod
def success(cls, data=None, status=StatusCodeEnum.OK, msg=None):
return cls(data, status, msg)
@classmethod
def error(cls, data=None, status=StatusCodeEnum.ERROR, msg=None):
return cls(data, status, msg)
def to_dict(self):
return {
"code": self.code,
"msg": self.msg,
"data": self.data
}
# 支持的下载器种类
class DownloaderCategory(models.TextChoices):
# 下载器名称
# Deluge = 'De', 'Deluge'
# Transmission = 'Tr', 'Transmission'
qBittorrent = 'Qb', 'qBittorrent'
class TorrentBaseInfo:
category_list = {
0: "空类型",
1: "电影Movies",
2: "电视剧TV Series",
3: "综艺TV Shows",
4: "纪录片Documentaries",
5: "动漫Animations",
6: "音乐视频Music Videos",
7: "体育Sports",
8: "音乐Music",
9: "电子书Ebook",
10: "软件Software",
11: "游戏Game",
12: "资料Education",
13: "旅游Travel",
14: "美食Food",
15: "其他Misc",
}
sale_list = {
1: '无优惠',
2: 'Free',
3: '2X',
4: '2XFree',
5: '50%',
6: '2X50%',
7: '30%',
8: '6xFree'
}
class Trigger(models.TextChoices):
# date = 'date', '单次任务'
interval = 'interval', '间隔任务'
cron = 'cron', 'cron任务'
class PushConfig(models.TextChoices):
# date = 'date', '单次任务'
wechat_work_push = 'wechat_work_push', '企业微信通知'
wxpusher_push = 'wxpusher_push', 'WxPusher通知'
pushdeer_push = 'pushdeer_push', 'PushDeer通知'
bark_push = 'bark_push', 'Bark通知'
class OCRConfig(models.TextChoices):
# date = 'date', '单次任务'
baidu_aip = 'baidu_aip', '百度OCR'

206
ptools/settings.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Django settings for djangoProject project.
Generated by 'django-admin startproject' using Django 4.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-6wrh^t$@gbb^s^=79@%cv=%yhq6gl^kane#g@-n-*n6+s1lo2f'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'simpleui',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'import_export',
'django_apscheduler',
'pt_site',
'auto_pt',
]
MIDDLEWARE = [
# 'django.middleware.cache.UpdateCacheMiddleware', # redis1
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 'django.middleware.cache.FetchFromCacheMiddleware', # redis2
]
ROOT_URLCONF = 'ptools.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'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',
],
},
},
]
WSGI_APPLICATION = 'ptools.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db/db.sqlite3',
'OPTIONS': {
'timeout': 60,
'check_same_thread': False
}
},
# 'default': {
# 'ENGINE': 'django.db.backends.mysql', # 数据库引擎
# 'NAME': 'pt', # 数据库名,先前创建的
# 'USER': 'pt', # 用户名,可以自己创建用户
# 'PASSWORD': 'bfmAjPysaFkmWsfs', # 密码
# 'HOST': 'docker_db_1', # mysql服务所在的主机ip
# 'PORT': '3306', # mysql服务端口
# },
# 'default': {
# 'ENGINE': 'django.db.backends.mysql', # 数据库引擎
# 'NAME': 'pt', # 数据库名,先前创建的
# 'USER': 'pt', # 用户名,可以自己创建用户
# 'PASSWORD': 'bfmAjPysaFkmWsfs', # 密码
# 'HOST': 'bt.9oho.cn', # mysql服务所在的主机ip
# 'PORT': '3306', # mysql服务端口
# }
}
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
# 'LOCATION': "redis://127.0.0.1:6333",
'LOCATION': "redis://127.0.0.1:6379/0",
'TIMEOUT': 200, # NONE 永不超时
'OPTIONS': {
# "PASSWORD": "", # 密码,没有可不设置
'CLIENT_CLASS': 'django_redis.client.DefaultClient', # redis-py 客户端
'PICKLE_VERSION': -1, # 插件使用PICKLE进行序列化,-1表示最新版本
'CONNECTION_POOL_KWARGS': {"max_connections": 100}, # 连接池最大连接数
'SOCKET_CONNECT_TIMEOUT': 5, # 连接超时
'SOCKET_TIMEOUT': 5, # 读写超时
}
# "KEY_PREFIX ":"test",#前缀
}
}
# Password validation
# https://docs.djangoproject.com/en/4.0/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/4.0/topics/i18n/
LANGUAGE_CODE = 'zh-Hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = 'static/'
# STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = (
os.path.join(os.path.join(BASE_DIR, 'static')),
)
MEDIA_URL = 'media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
APSCHEDULER_DATETIME_FORMAT = 'Y-m-d H:i:s' # Default
# 自定义配置
SIMPLEUI_HOME_TITLE = 'PT一下你就知道'
SIMPLEUI_HOME_ICON = 'fa fa-optin-monster'
SIMPLEUI_HOME_INFO = False
SIMPLEUI_LOGO = '/static/logo1.png'
# SIMPLEUI配置
SIMPLEUI_CONFIG = {
'system_keep': True,
# 'menu_display': ['下载管理', ], # 开启排序和过滤功能, 不填此字段为默认排序和全部显示, 空列表[] 为全部不显示.
'dynamic': True, # 设置是否开启动态菜单, 默认为False. 如果开启, 则会在每次用户登陆时动态展示菜单内容
'menus': [{
'app': 'downloader',
'name': '下载管理',
'icon': 'fas fa-user-shield',
'models': [{
'name': '任务管理',
'icon': 'fa fa-user',
'url': '/downloader/downloading/index'
}, {
'name': '查询种子',
'icon': 'fa fa-user',
'url': '/downloader/ptspider/index'
}]
}, {
'app': 'update',
'name': '更新',
'icon': 'fas fa-shield',
'models': [{
'name': '重启更新',
'icon': 'fa fa-refresh',
'url': '/tasks/restart'
}, ]
}]
}

33
ptools/urls.py Normal file
View File

@@ -0,0 +1,33 @@
"""djangoProject URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.0/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'))
"""
from django.contrib import admin
from django.shortcuts import redirect
from django.urls import path, include
from pt_site.views import *
def index(request):
return redirect(to='/admin')
urlpatterns = [
path('', index),
path(r'admin/', admin.site.urls),
path(r'tasks/', include("auto_pt.urls"), name='tasks'), #
path(r'site/', include("pt_site.urls"), name='tasks') #
]

16
ptools/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for djangoProject 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/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ptools.settings')
application = get_wsgi_application()

127
readme.md Normal file
View File

@@ -0,0 +1,127 @@
# pt助手开发
[![simpleui](https://img.shields.io/badge/developing%20with-Simpleui-2077ff.svg)](https://github.com/newpanjing/simpleui)
### 基本信息
1. 技术栈Docker、Python、Sqlite3,celery
2. 开发工具pycharm
3. 部署方式docker-compose部署
4. 用到的Python包
```bash
# DJango后台美化
pip install django-simpleui
# 汉字简繁转化
pip install opencc
# 破cf盾
pip install cloudscraper
# django定时任务
pip install django-apscheduler
# django redis支持
pip install django-redis
# 下载器接口
pip install transmission-rpc qbittorrent-api deluge-client
```
### 功能实现
| 日期 | 功能 | 实现 |
| ------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| 0717 | 站点管理(内附爬虫规则、站点授权信息) | 已实现,支持站点见下表 |
| 0718 | 种子信息抓取并保存 | 已实现 |
| 0719 | 下载器管理 | 已实现目前仅支持TransmissionqBittorrent下载器添加任务不返回任务信息需迂回容后处理 |
| 0720 | 推送种子到下载器 | 已实现 |
| 0721 | 推送种子后下载任务与种子信息关联 | 已关联但目前仅支持Tr |
| 0725 | 实现可签到站点的签到功能,支持一键多站签到 | 已实现 |
| 0729 | 实现站点个人数据抓取,天空个人主页盾比较强力,目前迂回获取数据,不是很全 | |
| 0731 | 获取信息时直接签到(如果没有当天数据或者当天数据中签到为否,则执行签到) | |
| 0801 | 多线程开发 | 已实现 |
| 0802 | 重构部分代码,将用户数据与站点配置文件分离,防止误操作 | 已完成 |
| 0805 | docker部署 | 已实现 |
| 0806 | 定时任务 | 已实现 |
| 0808 | 通知服务 | 已实现企业微信需要可信IP可能需要自行添加 |
| - | 实时监控种子上传下载信息开发下载器主页通过hash与种子关联并获取种子促销信息 | 未开发 |
| - | 后台推送种子信息 | 计划内,未开发 |
### 更新日志
- > 2022.08.13
- > -> 正式命名为PTools感谢群友
- > -> 支持铂金家
- > -> 提供了通用配置,可自行添加站点(网站细节不同,不保证能用)
- > -> 支持数据导入导出
- > -> 提供docker支持映射数据库文件
- > 2022.08.12
- > -> 优化签到信息显示无签到功能的无需已签到v未签到x
- > -> 优化获取个人数据提示,返回信息加上数据
- > -> 修复部分不能访问个人主页页面导致签到失败的bug
- > -> 增加时魔显示
- > 2022.08.10
- > -> 增加HD天空验证码签到功能开关
- > -> 调整数据库,历次个人数据展示到我的站点详情页
- > 2022.08.08
- > -> 对目前已完成功能进行优化,并清理冗余代码
- > -> 打包新版Docker镜像并推送
- > 2022.08.07
- > -> 实现企业微信通知需要自行抓取个人公网IP
- > 2022.08.05
- > -> 自动化代码部署完毕,实现签到、拉取个人数据以及拉取首页促销种子的自动化
- > 2022.08.03
- > -> 获取个人数据的代码已经改造完毕
- > -> 调整抓取种子的代码,降低对数据库的消耗
- > -> 调整代码后sqlite3数据库已经满足需求已切回sqlite3
- > 2022.08.02
- > -> 重构部分代码,将用户数据与站点配置文件分离,避免用户误操作
- > -> 增加排序ID用户数据根据站点排序进行排序
- > 2022.08.01
- > -> 切换使用Mysql数据库性能瓶颈问题已解决
- > 2022.07.31
- > -> 信息抓取使用多线程大幅度降低等待时间但是在数据库写入时Sqlite3本地数据库遇到性能瓶颈出现无法写入的bug
### 站点支持列表
> 2022.08.03支持列表,根本本人现有站点数据整理
| 序号 | 站点 | 获取种子 | 签到 | 个人数据 | 推送种子 | 备注 |
| ------ | ------------------ | ---------- | ------ | ---------- | ---------- | -------------------------------------- |
| 1 | 阿童木 | 支持 | 支持 | 支持 | 支持 | |
| 2 | 猪猪网 | 支持 | 支持 | 支持 | 支持 | |
| 3 | 学校 | 支持 | 支持 | 支持 | 支持 | 由于更新了防御盾,时灵时不灵 |
| 4 | 1PT | 支持 | 支持 | 支持 | 支持 | |
| 5 | ASL | 支持 | 支持 | 支持 | 支持 | |
| 6 | CarPT | 支持 | 支持 | 支持 | 支持 | |
| 7 | 高清视界HDArea | 支持 | 支持 | 支持 | 支持 | |
| 8 | 红豆饭HDFans | 支持 | 支持 | 支持 | 支持 | |
| 9 | 时光HDTIME | 支持 | 支持 | 支持 | 支持 | |
| 10 | MTeam | 支持 | 支持 | 支持 | 支持 | |
| 11 | HDZONE | 支持 | 支持 | 支持 | 支持 | |
| 12 | 冬樱WinterSakura | 支持 | 支持 | 支持 | 支持 | |
| 13 | 蚂蚁HDMayi | 支持 | 支持 | 支持 | 支持 | |
| 14 | 自由农场 | 支持 | 支持 | 支持 | 支持 | |
| 15 | 铂金学院 | 支持 | 支持 | 不支持 | 支持 | |
| 16 | 烧包 | 支持 | 无需 | 支持 | 支持 | |
| 17 | 海棠 | 支持 | 支持 | 支持 | 支持 | |
| 18 | 欧神 | 支持 | 支持 | 支持 | 支持 | |
| 19 | 时间PTT | 支持 | 支持 | 支持 | 支持 | |
| 20 | 海带 | 支持 | 无需 | 支持 | 支持 | |
| 21 | 白兔 | 支持 | 支持 | 支持 | 支持 | |
| 22 | 芒果 | 支持 | 支持 | 支持 | 支持 | |
| 23 | 艾薇 | 支持 | 无需 | 支持 | 支持 | |
| 24 | 老师 | 支持 | 支持 | 支持 | 支持 | |
| 25 | 马杀鸡 | 支持 | 无需 | 支持 | 支持 | |
| 26 | 欧绅 | 支持 | 无需 | 支持 | 支持 | |
| 27 | 备胎 | 支持 | 无需 | 支持 | 支持 | |
| 28 | 观众 | 支持 | 支持 | 支持 | 支持 | |
| 29 | 丐帮 | 支持 | 支持 | 支持 | 支持 | |
| 30 | 明教 | 支持 | 支持 | 支持 | 支持 | |
| 21 | 天空HDSKY | 支持 | 支持 | 支持 | 不支持 | 个人主页加盾,暂时无法突破,迂回处理 |
| 32 | 杜比 | 支持 | 支持 | 支持 | 不支持 | |
| 33 | 海胆 | 暂不支持 | 支持 | 支持 | 不支持 | |
| 34 | 海豹 | 不支持 | 无需 | 不支持 | 不支持 | |
| 35 | 明教 | 支持 | 支持 | 支持 | 支持 | |
### 捐助记录
- ## 感谢大佬捐助支持本项目!!!
- > viichien 大佬第一个捐助本项目,使我更有动力继续写下去!
>

50
requirements.txt Normal file
View File

@@ -0,0 +1,50 @@
APScheduler==3.9.1
asgiref==3.5.2
async-timeout==4.0.2
baidu-aip==4.16.7
certifi==2022.6.15
chardet==5.0.0
charset-normalizer==2.1.1
cloudscraper==1.2.60
defusedxml==0.7.1
deluge-client==1.9.0
Deprecated==1.2.13
diff-match-patch==20200713
Django==4.1
django-apscheduler==0.6.2
django-import-export==2.8.0
django-redis==5.2.0
django-simpleui==2022.7.29
et-xmlfile==1.1.0
idna==3.3
importlib-metadata==4.12.0
lxml==4.9.1
Markdown==3.4.1
MarkupPy==1.14
odfpy==1.4.1
OpenCC==1.1.4
openpyxl==3.0.10
packaging==21.3
pyparsing==3.0.9
pypushdeer==0.0.3
pytz==2022.2.1
pytz-deprecation-shim==0.1.0.post0
PyYAML==6.0
qbittorrent-api==2022.8.36
redis==4.3.4
requests==2.28.1
requests-toolbelt==0.9.1
six==1.16.0
sqlparse==0.4.2
tablib==3.2.1
transmission-rpc==3.3.2
typing_extensions==4.3.0
tzdata==2022.2
tzlocal==4.2
urllib3==1.26.11
wechat-push==1.0.1
wrapt==1.14.1
wxpusher==2.2.0
xlrd==2.0.1
xlwt==1.3.0
zipp==3.8.1

33
start.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# 安装依赖
pip install -r requirements.txt
CONTAINER_ALREADY_STARTED="CONTAINER_ALREADY_STARTED_PLACEHOLDER"
if [ ! -f ./db/db.sqlite3 ]; then
echo "-- 初始化数据库 init database --"
# 如果数据库存在,就不执行
python manage.py makemigrations &&
python manage.py migrate &&
python manage.py loaddata db/pt.json
# mv db.sqlite3 ./db/db.sqlite3
fi
if [ ! -e $CONTAINER_ALREADY_STARTED ]; then
touch $CONTAINER_ALREADY_STARTED
echo "-- First container startup --"
# 此处插入你要执行的命令或者脚本文件
# 安装依赖
# mv db.sqlite3 ./db/sqlite3 &&
# 导入数据 person.json为自定义
# python manage.py loaddata person.json &&
# 创建超级用户
DJANGO_SUPERUSER_USERNAME=$DJANGO_SUPERUSER_USERNAME \
DJANGO_SUPERUSER_EMAIL=$DJANGO_SUPERUSER_EMAIL \
DJANGO_SUPERUSER_PASSWORD=$DJANGO_SUPERUSER_PASSWORD \
python manage.py createsuperuser --noinput
else
echo "-- Not first container startup --"
fi
#python manage.py migrate &&
# python manage.py runserver 0.0.0.0:8001 --noreload

View File

@@ -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;
}

1089
static/admin/css/base.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
/* 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: 19px;
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 ul.toplinks {
display: block;
float: left;
padding: 0;
margin: 0;
width: 100%;
}
.change-list ul.toplinks li {
padding: 3px 6px;
font-weight: bold;
list-style-type: none;
display: inline-block;
}
.change-list ul.toplinks .date-back a {
color: var(--body-quiet-color);
}
.change-list ul.toplinks .date-back a:focus,
.change-list ul.toplinks .date-back a:hover {
color: var(--link-hover-color);
}
/* ACTIONS */
.filtered .actions {
border-right: none;
}
#changelist table input {
margin: 0;
vertical-align: baseline;
}
#changelist table tbody tr.selected {
background-color: var(--selected-row);
}
#changelist .actions {
padding: 10px;
background: var(--body-bg);
border-top: none;
border-bottom: none;
line-height: 24px;
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: 24px;
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: 24px;
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);
}

View File

@@ -0,0 +1,33 @@
@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;
}
}

View File

@@ -0,0 +1,26 @@
/* DASHBOARD */
.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;
}

View File

@@ -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;
}

528
static/admin/css/forms.css Normal file
View File

@@ -0,0 +1,528 @@
@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;
}
/* 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;
float: left;
width: 160px;
word-wrap: break-word;
line-height: 1;
}
.aligned label:not(.vCheckboxLabel):after {
content: '';
display: inline-block;
vertical-align: middle;
height: 26px;
}
.aligned label + p, .aligned label + div.help, .aligned label + div.readonly {
padding: 6px 0;
margin-top: 0;
margin-bottom: 0;
margin-left: 170px;
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 {
clear: left;
margin-top: 0;
margin-left: 160px;
padding-left: 10px;
}
form .aligned label + p.help,
form .aligned label + div.help {
margin-left: 0;
padding-left: 0;
}
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;
}
.checkbox-row p.help,
.checkbox-row div.help {
margin-left: 0;
padding-left: 0;
}
fieldset .fieldBox {
float: left;
margin-right: 20px;
}
/* WIDE FIELDSETS */
.wide label {
width: 200px;
}
form .wide p,
form .wide input + p.help,
form .wide input + div.help {
margin-left: 200px;
}
form .wide p.help,
form .wide div.help {
padding-left: 38px;
}
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: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace;
}
/* SUBMIT ROW */
.submit-row {
padding: 12px 14px 7px;
margin: 0 0 20px;
background: var(--darkened-bg);
border: 1px solid var(--hairline-color);
border-radius: 4px;
text-align: right;
overflow: hidden;
}
body.popup .submit-row {
overflow: auto;
}
.submit-row input {
height: 35px;
line-height: 15px;
margin: 0 0 5px 5px;
}
.submit-row input.default {
margin: 0 0 5px 8px;
text-transform: uppercase;
}
.submit-row p {
margin: 0.3em;
}
.submit-row p.deletelink-box {
float: left;
margin: 0;
}
.submit-row a.deletelink {
display: block;
background: var(--delete-button-bg);
border-radius: 4px;
padding: 10px 15px;
height: 15px;
line-height: 15px;
margin-bottom: 5px;
color: var(--button-fg);
}
.submit-row a.closelink {
display: inline-block;
background: var(--close-button-bg);
border-radius: 4px;
padding: 10px 15px;
height: 15px;
line-height: 15px;
margin: 0 0 0 5px;
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);
}
.submit-row a.closelink:focus,
.submit-row a.closelink:hover,
.submit-row a.closelink:active {
background: var(--close-button-hover-bg);
}
/* 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: 16px;
height: 16px;
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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,139 @@
.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.shifted > #nav-sidebar {
margin-left: 0;
}
[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%;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
/* 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 {
margin-right: 0;
}
[dir="rtl"] #changelist-filter {
margin-left: 0;
margin-right: 0;
}
}

239
static/admin/css/rtl.css Normal file
View File

@@ -0,0 +1,239 @@
/* 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;
float: right;
}
.submit-row {
text-align: left
}
.submit-row p.deletelink-box {
float: right;
}
.submit-row input.default {
margin-left: 0;
}
.vDateField, .vTimeField {
margin-left: 2px;
}
.aligned .form-row input {
margin-left: 5px;
}
form .aligned p.help, form .aligned div.help {
clear: right;
}
form .aligned ul {
margin-right: 163px;
margin-left: 0;
}
form ul.inline li {
float: right;
padding-right: 0;
padding-left: 7px;
}
input[type=submit].default, .submit-row input.default {
float: left;
}
fieldset .fieldBox {
float: right;
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;
}
.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;
}

View File

@@ -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.

View File

@@ -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; }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,580 @@
/* 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-chosen select {
border-top: none;
}
.selector-available h2, .selector-chosen h2 {
border: 1px solid var(--border-color);
border-radius: 4px 4px 0 0;
}
.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 {
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-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: 18px;
width: 18px;
}
/* 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;
}
.aligned p.file-upload {
margin-left: 170px;
}
.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: 11px;
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;
}

View File

@@ -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.

View File

@@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
static/admin/img/LICENSE Normal file
View File

@@ -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.

View File

@@ -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).

View File

@@ -0,0 +1,14 @@
<svg width="15" height="60" viewBox="0 0 1792 7168" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="previous">
<path d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="next">
<path d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#previous" x="0" y="0" fill="#333333" />
<use xlink:href="#previous" x="0" y="1792" fill="#000000" />
<use xlink:href="#next" x="0" y="3584" fill="#333333" />
<use xlink:href="#next" x="0" y="5376" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#EBECE6" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9C9C9" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#F1C02A" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9A741" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#70bf2b" d="M1600 796v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M1024 1375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 23.5v190q0 14 9.5 23.5t22.5 9.5h192q13 0 22.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11 0-24 11-10 7-10 21l17 457q0 10 10 16.5t24 6.5h185q14 0 23.5-6.5t10.5-16.5zm-14-934l768 1408q35 63-2 126-17 29-46.5 46t-63.5 17h-1536q-34 0-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31 47-49t65-18 65 18 47 49z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#666666" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#70bf2b" d="M1412 734q0-28-18-46l-91-90q-19-19-45-19t-45 19l-408 407-226-226q-19-19-45-19t-45 19l-91 90q-18 18-18 46 0 27 18 45l362 362q19 19 45 19 27 0 46-19l543-543q18-18 18-45zm252 162q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#999999" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@@ -0,0 +1,3 @@
<svg width="15" height="15" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#555555" d="M1216 832q0-185-131.5-316.5t-316.5-131.5-316.5 131.5-131.5 316.5 131.5 316.5 316.5 131.5 316.5-131.5 131.5-316.5zm512 832q0 52-38 90t-90 38q-54 0-90-38l-343-342q-179 124-399 124-143 0-273.5-55.5t-225-150-150-225-55.5-273.5 55.5-273.5 150-225 225-150 273.5-55.5 273.5 55.5 225 150 150 225 55.5 273.5q0 220-124 399l343 343q37 37 37 90z"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,34 @@
<svg width="16" height="192" viewBox="0 0 1792 21504" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="up">
<path d="M1412 895q0-27-18-45l-362-362-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45v-502l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="down">
<path d="M1412 897q0-27-18-45l-91-91q-18-18-45-18t-45 18l-189 189v-502q0-26-19-45t-45-19h-128q-26 0-45 19t-19 45v502l-189-189q-19-19-45-19t-45 19l-91 91q-18 18-18 45t18 45l362 362 91 91q18 18 45 18t45-18l91-91 362-362q18-18 18-45zm252-1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="left">
<path d="M1408 960v-128q0-26-19-45t-45-19h-502l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18l-362 362-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45l-189-189h502q26 0 45-19t19-45zm256-64q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="right">
<path d="M1413 896q0-27-18-45l-91-91-362-362q-18-18-45-18t-45 18l-91 91q-18 18-18 45t18 45l189 189h-502q-26 0-45 19t-19 45v128q0 26 19 45t45 19h502l-189 189q-19 19-19 45t19 45l91 91q18 18 45 18t45-18l362-362 91-91q18-18 18-45zm251 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="clearall">
<path transform="translate(336, 336) scale(0.75)" d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="chooseall">
<path transform="translate(336, 336) scale(0.75)" d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#up" x="0" y="0" fill="#666666" />
<use xlink:href="#up" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#down" x="0" y="3584" fill="#666666" />
<use xlink:href="#down" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#left" x="0" y="7168" fill="#666666" />
<use xlink:href="#left" x="0" y="8960" fill="#447e9b" />
<use xlink:href="#right" x="0" y="10752" fill="#666666" />
<use xlink:href="#right" x="0" y="12544" fill="#447e9b" />
<use xlink:href="#clearall" x="0" y="14336" fill="#666666" />
<use xlink:href="#clearall" x="0" y="16128" fill="#447e9b" />
<use xlink:href="#chooseall" x="0" y="17920" fill="#666666" />
<use xlink:href="#chooseall" x="0" y="19712" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,19 @@
<svg width="14" height="84" viewBox="0 0 1792 10752" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="sort">
<path d="M1408 1088q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45zm0-384q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="ascending">
<path d="M1408 1216q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="descending">
<path d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"/>
</g>
</defs>
<use xlink:href="#sort" x="0" y="0" fill="#999999" />
<use xlink:href="#sort" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#ascending" x="0" y="3584" fill="#999999" />
<use xlink:href="#ascending" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#descending" x="0" y="7168" fill="#999999" />
<use xlink:href="#descending" x="0" y="8960" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@@ -0,0 +1,112 @@
'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);
},
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;
}

View File

@@ -0,0 +1,218 @@
/*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 <p class="info">, 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);
}
}
// <div class="selector"> or <div class="selector stacked">
const selector_div = quickElement('div', from_box.parentNode);
selector_div.className = is_stacked ? 'selector stacked' : 'selector';
// <div class="selector-available">
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';
// <ul class="selector-chooser">
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';
// <div class="selector-chosen">
const selector_chosen = quickElement('div', selector_div);
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 to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name);
to_box.className = 'filtered';
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);
}
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');
});
filter_input.addEventListener('keypress', function(e) {
SelectFilter.filter_key_press(e, field_id);
});
filter_input.addEventListener('keyup', function(e) {
SelectFilter.filter_key_up(e, field_id);
});
filter_input.addEventListener('keydown', function(e) {
SelectFilter.filter_key_down(e, field_id);
});
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.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_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'));
},
filter_key_press: function(event, field_id) {
const from = document.getElementById(field_id + '_from');
// don't submit form if user pressed Enter
if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) {
from.selectedIndex = 0;
SelectBox.move(field_id + '_from', field_id + '_to');
from.selectedIndex = 0;
event.preventDefault();
}
},
filter_key_up: function(event, field_id) {
const from = document.getElementById(field_id + '_from');
const temp = from.selectedIndex;
SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value);
from.selectedIndex = temp;
},
filter_key_down: function(event, field_id) {
const from = document.getElementById(field_id + '_from');
// right arrow -- move across
if ((event.which && event.which === 39) || (event.keyCode && event.keyCode === 39)) {
const old_index = from.selectedIndex;
SelectBox.move(field_id + '_from', field_id + '_to');
from.selectedIndex = (old_index === from.length) ? from.length - 1 : old_index;
return;
}
// down arrow -- wrap around
if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) {
from.selectedIndex = (from.length === from.selectedIndex + 1) ? 0 : from.selectedIndex + 1;
}
// up arrow -- wrap around
if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) {
from.selectedIndex = (from.selectedIndex === 0) ? from.length - 1 : from.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));
});
});
}

201
static/admin/js/actions.js Normal file
View File

@@ -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 havent saved your changes to individual fields yet. Please click OK to save. Youll need to re-run the action.")
: gettext("You have selected an action, and you havent made any changes on individual fields. Youre 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);
}
});
}

View File

@@ -0,0 +1,409 @@
/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/
// Inserts shortcut buttons after all of the following:
// <input type="text" class="vDateField">
// <input type="text" class="vTimeField">
'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 <div> that gets toggled
calendarDivName2: 'calendarin', // name of <div> that contains calendar
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
clockDivName: 'clockbox', // name of clock <div> 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('span');
warning.className = warningClass;
warning.textContent = message;
inp.parentNode.appendChild(document.createElement('br'));
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:
// <div id="clockbox1" class="clockbox module">
// <h2>Choose a time</h2>
// <ul class="timelist">
// <li><a href="#">Now</a></li>
// <li><a href="#">Midnight</a></li>
// <li><a href="#">6 a.m.</a></li>
// <li><a href="#">Noon</a></li>
// <li><a href="#">6 p.m.</a></li>
// </ul>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
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 <input>.
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:
//
// <div id="calendarbox3" class="calendarbox module">
// <h2>
// <a href="#" class="link-previous">&lsaquo;</a>
// <a href="#" class="link-next">&rsaquo;</a> February 2003
// </h2>
// <div class="calendar" id="calendarin3">
// <!-- (cal) -->
// </div>
// <div class="calendar-shortcuts">
// <a href="#">Yesterday</a> | <a href="#">Today</a> | <a href="#">Tomorrow</a>
// </div>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
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;
}

View File

@@ -0,0 +1,239 @@
/*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) {
name = name + "__" + (popupIndex + 1);
return name;
}
function removePopupIndex(name) {
name = name.replace(new RegExp("__" + (popupIndex + 1) + "$"), '');
return name;
}
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 '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select`);
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);
}
});
});
}

View File

@@ -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();
});
}

221
static/admin/js/calendar.js Normal file
View File

@@ -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;
}

29
static/admin/js/cancel.js Normal file
View File

@@ -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);
});
});
}

View File

@@ -0,0 +1,33 @@
/*global gettext*/
'use strict';
{
const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName;
let submitted = false;
if (modelName) {
const form = document.getElementById(modelName + '_form');
form.addEventListener('submit', (event) => {
event.preventDefault();
if (submitted) {
const answer = window.confirm(gettext('You have already submitted this form. Are you sure you want to submit it again?'));
if (!answer) {
return;
}
}
;
event.target.submit();
submitted = true;
});
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;
}
}
}
}

View File

@@ -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);
});
});
}

170
static/admin/js/core.js Normal file
View File

@@ -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 = result + fields[format.charAt(i + 1)];
++i;
}
else {
result = 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));
};
}

View File

@@ -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='${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));
});
});
}

359
static/admin/js/inlines.js Normal file
View File

@@ -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('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a href="#">' + options.addText + "</a></tr>");
addButton = $parent.find("tr:last a");
} else {
// Otherwise, insert it immediately after the last form:
$this.filter(":last").after('<div class="' + options.addCssClass + '"><a href="#">' + options.addText + "</a></div>");
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('<div><a class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></div>");
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append('<li><a class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></li>");
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row.children(":first").append('<span><a class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></span>");
}
// 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;
}
});
});
}

View File

@@ -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)};

View File

@@ -0,0 +1,102 @@
'use strict';
{
const toggleNavSidebar = document.getElementById('toggle-nav-sidebar');
if (toggleNavSidebar !== null) {
const navLinks = document.querySelectorAll('#nav-sidebar a');
function disableNavLinkTabbing() {
for (const navLink of navLinks) {
navLink.tabIndex = -1;
}
}
function enableNavLinkTabbing() {
for (const navLink of navLinks) {
navLink.tabIndex = 0;
}
}
function disableNavFilterTabbing() {
document.getElementById('nav-filter').tabIndex = -1;
}
function enableNavFilterTabbing() {
document.getElementById('nav-filter').tabIndex = 0;
}
const main = document.getElementById('main');
let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen');
if (navSidebarIsOpen === null) {
navSidebarIsOpen = 'true';
}
if (navSidebarIsOpen === 'false') {
disableNavLinkTabbing();
disableNavFilterTabbing();
}
main.classList.toggle('shifted', navSidebarIsOpen === 'true');
toggleNavSidebar.addEventListener('click', function() {
if (navSidebarIsOpen === 'true') {
navSidebarIsOpen = 'false';
disableNavLinkTabbing();
disableNavFilterTabbing();
} else {
navSidebarIsOpen = 'true';
enableNavLinkTabbing();
enableNavFilterTabbing();
}
localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen);
main.classList.toggle('shifted');
});
}
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 <TR>
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();
}

View File

@@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More