Compare commits
235 Commits
master
...
port-table
Author | SHA1 | Date | |
---|---|---|---|
45322df5b6 | |||
078390ea81 | |||
5657d192b1 | |||
c01b20c06a | |||
49711ccad1 | |||
c89047ab13 | |||
0fe99ae652 | |||
6f8098eb12 | |||
58648c8646 | |||
b039db8517 | |||
bf887db7cb | |||
7d0f4d6f58 | |||
38a6ada67e | |||
3dd5da4acd | |||
1cd1199e28 | |||
9f18ae3f13 | |||
7618eb3702 | |||
dad27da365 | |||
f9fff0731c | |||
97748dcaf6 | |||
43ec85faf4 | |||
2d1ccb4a00 | |||
233968634b | |||
004e7b8b11 | |||
c278614e86 | |||
e0685620bb | |||
87a84e248b | |||
1593c3b370 | |||
1573393311 | |||
0d37e6fd8e | |||
ed7dab3147 | |||
c5595ab823 | |||
19c1657813 | |||
4bad3fb45b | |||
8a563ca51e | |||
2571630c41 | |||
7705ff1fcb | |||
507b60e54e | |||
c10c1ff511 | |||
1d6e7c6884 | |||
38b8184499 | |||
36a744159f | |||
0b50b2bb7f | |||
306b24988f | |||
d7a30b9139 | |||
180a646267 | |||
a3279a4c91 | |||
b671c59a48 | |||
082cf2989d | |||
56e125c0bf | |||
c8bae087f9 | |||
eceee915fa | |||
3d4d5733b2 | |||
16e468f2f3 | |||
65f12d9efa | |||
170c06ccab | |||
1073309646 | |||
5949d72541 | |||
b2e75e6924 | |||
35c8e7582b | |||
b324b6accb | |||
aff45f9ac5 | |||
4646c0a564 | |||
2d3f4b6294 | |||
5f0251bd87 | |||
c4ce4b28a0 | |||
41211e61bd | |||
e4e0264fd4 | |||
15ce3c1dbb | |||
49dfb59d71 | |||
b8ff2ae303 | |||
9ff2cdd1f1 | |||
e07ea1f832 | |||
0b153d14bc | |||
048e293fb2 | |||
ccb383fed8 | |||
77c1787f65 | |||
1e5921f23a | |||
aefdcca369 | |||
4dcd1f71fb | |||
3e4112c9cf | |||
068d395a4b | |||
9b5d4a9d99 | |||
204bc07739 | |||
9f76da4aa4 | |||
ef91169116 | |||
118b0d4bdd | |||
09b910454e | |||
cfd7af3087 | |||
ded11af42b | |||
64b4e95bd1 | |||
7cd47165a9 | |||
0ffc189a8b | |||
acc17cc9db | |||
bd4ac4adcc | |||
41771f40e0 | |||
1d8be63834 | |||
616428cf4d | |||
21e8088476 | |||
ae23d0510d | |||
449dff8141 | |||
2da4018483 | |||
709b650f2e | |||
f095cf717b | |||
abbb4fbb62 | |||
183f888106 | |||
760efda35a | |||
fcde18b381 | |||
40f8c9f899 | |||
c8cd549ca8 | |||
c6ad0b01e2 | |||
9002eaf807 | |||
6127233c0f | |||
da8ed90686 | |||
199daf913e | |||
14c2c1acca | |||
443fb58d17 | |||
6a9ed93054 | |||
f7ab26db35 | |||
64b569fd4d | |||
024a26325b | |||
1c4efd0da2 | |||
45aa4ee261 | |||
bf6ed0fd45 | |||
b396134bea | |||
1eac879ea9 | |||
e6d0cea361 | |||
7f151366b1 | |||
12e358df29 | |||
79384ff737 | |||
98335fce60 | |||
6664f2d4f5 | |||
186ba3d21a | |||
903f09279a | |||
876e7ba204 | |||
23d2d44c04 | |||
57e84b1722 | |||
0819bd8fcb | |||
303a40a840 | |||
1a4694421b | |||
dba6460180 | |||
08037d7846 | |||
5d92cd4f7f | |||
b7cc682280 | |||
2189563b2b | |||
8026e0cf84 | |||
21cde56f31 | |||
c364869170 | |||
711407bc58 | |||
13110044eb | |||
8ef9c9d9dd | |||
67ae103676 | |||
019d6be9f5 | |||
8133b2c665 | |||
babbcae8d9 | |||
72dece5b28 | |||
253f99adc8 | |||
aa9d2cdfbe | |||
b6ff653014 | |||
1b28600c39 | |||
db4dcc4175 | |||
68b5174222 | |||
ce263b23f4 | |||
66e41ab53d | |||
bc3a88e159 | |||
20e713d8df | |||
f4c0f0b0f3 | |||
3675bef22a | |||
293c02a6fd | |||
4c0928e10c | |||
014b30c3ec | |||
8bcd997b4d | |||
1b3aa12ee9 | |||
5e3653ca4a | |||
56db239248 | |||
a2acc7298c | |||
cc3c35be6d | |||
0530b06df4 | |||
4ebb33e732 | |||
b4bef2598f | |||
7ae2555920 | |||
12bff711bc | |||
d0c6c7c743 | |||
2d568cb913 | |||
6ae4b7f319 | |||
57dc57786c | |||
a70079d635 | |||
86074d2e13 | |||
8b803f5df0 | |||
edf047f148 | |||
9bcb99dd30 | |||
181930d687 | |||
515a165cdb | |||
ecfd4a37f3 | |||
345f70d30f | |||
454ba77908 | |||
73d72ce21e | |||
71b9b2f437 | |||
b99ef216eb | |||
3a41123868 | |||
286ac392d5 | |||
049beb778f | |||
1ae571489d | |||
bc04ec0996 | |||
8b97f63710 | |||
0b423a7e43 | |||
1e471bffcc | |||
8f0e89b35c | |||
55fd075c1d | |||
9cd020468f | |||
97cb956db5 | |||
f9fe43e17d | |||
634cd6fed3 | |||
76637c1d03 | |||
48290a2df1 | |||
63edbf7c59 | |||
3287227f62 | |||
7f580a0f1f | |||
3e85525f53 | |||
9fb0906723 | |||
3acb172432 | |||
0fe1633beb | |||
c173664d5a | |||
221ea5cc79 | |||
2b6546bd76 | |||
b9c0f237f7 | |||
b91d38dc81 | |||
6942fcc12e | |||
374ea516bd | |||
0c028417b0 | |||
a4db7f0eac | |||
135739f856 | |||
651622095e | |||
1d5117614e | |||
53ddffc3ba |
243
CHANGELOG.md
243
CHANGELOG.md
@ -1,73 +1,214 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
**v0.3.4**
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- Fix: Refresh data tables after deleting any number of entries (corbz/PYRSS-Website#38)
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
- Enhancement: `data-field` implementation for subscription form
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
- Enhancement: Improved the offcanvas navbar on smaller screens, to be less ugly
|
|
||||||
- Fix: Select2 dropdown search bars always using light theme, regardless of user choice
|
|
||||||
- Enhancement: Clearer help label on the 'Add Server' modal/form
|
|
||||||
- Enhancement: Add wiki link button to footer
|
|
||||||
- Fix: "ordering=unknown" by only applying ordering if it's truthy
|
|
||||||
- Fix: Exception caused by unfinished queryset method on the `/api/guild-settings/` endpoint
|
|
||||||
- Enhancement: rewrote `confirmDeleteModal` into less specific `confirmationModal` with specifiable styles
|
|
||||||
- Fix: Several potential xss attack vectors
|
|
||||||
- Fix: 'Add Pyrss' button incorrectly not opening in new tab
|
|
||||||
|
|
||||||
**v0.3.3**
|
## [Unreleased] [0.4.1] - xxxx-xx-xx
|
||||||
|
|
||||||
- Enhancement: Added some refreshing new fonts (sora & atkison hyperlegible)
|
### Added
|
||||||
- Enhancement: all table cells no longer wrap text
|
|
||||||
- Fix: fixed a padding issue with link-style buttons in table cells
|
|
||||||
- Enhancement: `DISCORD_SCOPES` as env var, rather than hard coded
|
|
||||||
- Other: added license file
|
|
||||||
|
|
||||||
**v0.3.2**
|
- 'Invalid' appearance for select-2 controls, upon bad form submission
|
||||||
|
- Shorthand functions for setting/clearing the "spot" classes on ".sidebar-item"s
|
||||||
|
|
||||||
- Fix: invite link refered to wrong Discord application, because the client Id was hard coded
|
### Fixed
|
||||||
- Enhancement: enabled pagination for the `/api/guild-settings/` endpoint
|
|
||||||
|
|
||||||
**v0.3.1**
|
- Storing Discord-provided snowflake ID's in a postgresql database. Now using 'PositiveLargeIntegerField' to support this
|
||||||
|
- Sidebar server "placeholder" elements running offscreen on desktop - fixed by reducing them by 2
|
||||||
|
- Theme button missing icon on page load. Created an init function for properly setting up the theme on page load.
|
||||||
|
|
||||||
- Fix: issues brought from previous version
|
### Changed
|
||||||
|
|
||||||
**breaking changes v.0.3.0**
|
- Keep the hover state of sidebar buttons while their respective dropdown's are showing
|
||||||
|
- Made the 'channels' field on subscriptions required, at least one channel must be chosen
|
||||||
|
- Deleting a used message style, will cause subscriptions using it to fall back to their default message styles
|
||||||
|
|
||||||
- Enhancement: store guild settings in separate model
|
## [Unreleased] [0.4.0] - xxxx-xx-xx [BREAKING]
|
||||||
- Breaking: server `default_embed_colour` setting will be reset due to changes on how settings are stored
|
|
||||||
- Enhancement: moved server settinsg from tab to pop-out modal
|
|
||||||
- Enhancement: `active` flag added as server setting, soft-toggles activeness of associated subscriptions
|
|
||||||
- Fix: Bad text alignment for some table button links
|
|
||||||
- Fix & Enhancement: some small clean-up/patches here and there
|
|
||||||
- Fix: Incorrect margin on 'server settings' & 'new server' modal buttons
|
|
||||||
- Fix: Table "X Results" text now correctly plural only if `results > 1`
|
|
||||||
|
|
||||||
**v0.2.2**
|
### Added
|
||||||
|
|
||||||
- Enhancement: added open graph meta tags
|
- `UniqueContentRule` model, allows the user to determine how unique RSS items are defined
|
||||||
- Fix: csrf trusted origins warning, from not including url protocol
|
- `unique_content_rules` attribute to the `Subscription` model, many-to-many relationship with the related model
|
||||||
- Fix/Enhancement: Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
|
- Web interface method of setting a Subscription's unique content rules, through a multi-select field
|
||||||
|
- Migrations to allow older versions to seemlessly upgrade for this change, by creating the `UniqueContentRule` instances and setting default content rules on Subscriptions
|
||||||
|
- Column on subscription table for the new unique content rules
|
||||||
|
- Validation indicators and messages to the sub modal fields
|
||||||
|
- Help text to various form fields that lacked it
|
||||||
|
|
||||||
**v0.2.1**
|
### Fixed
|
||||||
|
|
||||||
- Enhancement: Added confirmation modal for closing a server
|
- Footer links pointing to older domain
|
||||||
|
- Tracked content ListView API incorrectly ordering by oldest first, instead of newest first
|
||||||
|
|
||||||
**v0.2.0**
|
### Changed
|
||||||
|
|
||||||
- Enhancement: Improved warning when server doesn't include bot member
|
- General rewrite of entire web interface
|
||||||
- Enhancement: Made it easier to update labels to the current server's details
|
- General rewrite of entire backend
|
||||||
- Enhancement: 'Go Back' button on the server page, takes the user to the 'select a server' page.
|
- Web interface now uses the full device width, rather than a smaller maximum width
|
||||||
- Docs: Further documented `static/home/servers.js`
|
- Server sidebar use more width, also displays name and guild ID, becomes smaller on small devices
|
||||||
- Other: removed some unused/unreferenced code
|
- Update changelog to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||||
|
- Made the tables and their respective buttons more mobile friendly
|
||||||
|
- Tidy up the layout of switches on some forms
|
||||||
|
- Help text for the switch fields on the Filters form, to better indicate their function
|
||||||
|
- 'Invite PYRSS' yellow warning alert made to be mobile friendly
|
||||||
|
- Table controls (pagination & page resizer) adjusted to support mobile devices
|
||||||
|
- Moved footer items to bottom of sidebar
|
||||||
|
- Changed style of navbar buttons
|
||||||
|
|
||||||
**v0.1.14**
|
### Removed
|
||||||
|
|
||||||
- Fix: layout issue for the 'select a server' page was off-centre
|
- Unused code for the legacy 'server settings tab' from before it became a modal
|
||||||
|
|
||||||
**v0.1.13**
|
## [0.3.4] - 2024-09-12
|
||||||
|
|
||||||
- Docs: Start of changelog
|
### Added
|
||||||
- Fix: remove db flush from entrypoint file
|
|
||||||
|
|
||||||
**v0.1.0**
|
- Add wiki link button to footer
|
||||||
|
|
||||||
- Initial Release
|
### Fixed
|
||||||
|
|
||||||
|
- Refresh data tables after deleting any number of entries (corbz/PYRSS-Website#38)
|
||||||
|
- Select2 dropdown search bars always using light theme, regardless of user choice
|
||||||
|
- Fixed "ordering=unknown" in api calls by only applying ordering if it's truthy
|
||||||
|
- Exception caused by unfinished queryset method on the `/api/guild-settings/` endpoint
|
||||||
|
- Several potential xss attack vectors
|
||||||
|
- 'Add Pyrss' button incorrectly not opening in new tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `data-field` implementation for subscription form
|
||||||
|
- Improved the offcanvas navbar on smaller screens, to be less ugly
|
||||||
|
- Clearer help label on the 'Add Server' modal/form
|
||||||
|
- rewrote `confirmDeleteModal` into less specific `confirmationModal` with specifiable styles
|
||||||
|
|
||||||
|
## [0.3.3] - 2024-08-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added font: sora
|
||||||
|
- Added font: Atkinson Hyperlegible
|
||||||
|
- added license file
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a padding issue with link-style buttons in table cells
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- All table cells no longer wrap text
|
||||||
|
- `DISCORD_SCOPES` as env var, rather than hard coded
|
||||||
|
|
||||||
|
## [0.3.2] - 2024-08-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Invite link refered to wrong Discord application, because the client Id was hard coded
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enabled pagination for the `/api/guild-settings/` endpoint
|
||||||
|
|
||||||
|
## [0.3.1] - 2024-08-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Colour embeds with multiple hashtag characters, bug from [0.3.0]
|
||||||
|
|
||||||
|
## [0.3.0] - 2024-08-16 [BREAKING]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `active` flag added as server setting, soft-toggles activeness of associated subscriptions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bad text alignment for some table button links
|
||||||
|
- Incorrect margin on 'server settings' & 'new server' modal buttons
|
||||||
|
- Table "X Results" text now correctly plural only if `results > 1`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- store guild settings in separate model
|
||||||
|
- moved server settinsg from tab to pop-out modal
|
||||||
|
- server `default_embed_colour` setting will be reset due to changes on how settings are stored
|
||||||
|
|
||||||
|
## [0.2.2] - 2024-08-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added open graph meta tags
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- csrf trusted origins warning, from not including url protocol
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
|
||||||
|
|
||||||
|
## [0.2.1] - 2024-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added confirmation modal for closing a server
|
||||||
|
|
||||||
|
## [0.2.0] - 2024-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 'Go Back' button on the server page, takes the user to the 'select a server' page.
|
||||||
|
- Documented `static/home/servers.js` with comments
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved warning when server doesn't include bot member
|
||||||
|
- Made it easier to update labels to the current server's details
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed some unused/unreferenced code
|
||||||
|
|
||||||
|
## [0.1.15] - 2024-08-13
|
||||||
|
|
||||||
|
## [0.1.14] - 2024-08-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- layout issue for the 'select a server' page was off-centre
|
||||||
|
|
||||||
|
## [0.1.13] - 2024-08-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Start of changelog
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- remove db flush from entrypoint file
|
||||||
|
|
||||||
|
## [0.1.12] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.11] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.10] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.9] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.8] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.7] - 2024-08-12
|
||||||
|
|
||||||
|
## [0.1.6] - 2024-08-11
|
||||||
|
|
||||||
|
## [0.1.5] - 2024-08-11
|
||||||
|
|
||||||
|
## [0.1.4] - 2024-08-09
|
||||||
|
|
||||||
|
## [0.1.3] - 2024-08-08
|
||||||
|
|
||||||
|
## [0.1.2] - 2024-08-05
|
||||||
|
|
||||||
|
## [0.1.1] - 2024-08-04
|
||||||
|
|
||||||
|
## [0.1.0] - 2024-08-04
|
||||||
|
3
apps/api/errors.py
Normal file
3
apps/api/errors.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
class NotAMemberError(Exception):
|
||||||
|
pass
|
@ -2,22 +2,19 @@
|
|||||||
|
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from apps.home.models import Server
|
||||||
|
from apps.authentication.models import ServerMember
|
||||||
|
|
||||||
class UserHasDiscordPermissions(BasePermission):
|
|
||||||
|
class HasServerAccess(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permission to ensure that the user is permitted to make
|
An object permission class, the object must have a 'server' attribute.
|
||||||
changes on behalf of the server they are representing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
message = "You lack administrator access to this server"
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not hasattr(obj, "server"):
|
||||||
|
raise Exception(f"obj '{obj}' must have attr 'server'")
|
||||||
|
|
||||||
|
return ServerMember.objects.filter(user=request.user, server=obj.server).exists()
|
||||||
# class SubscriptionServerMember(BasePermission):
|
|
||||||
# """
|
|
||||||
# Permission for each subscription that omits the sub if
|
|
||||||
# the request user isn't a member of it's server.
|
|
||||||
# """
|
|
||||||
|
|
||||||
# def has_object_permission(self, request, view, obj):
|
|
||||||
|
|
||||||
# return obj.server in request.user.servers
|
|
||||||
|
27
apps/api/renderers.py
Normal file
27
apps/api/renderers.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
from rest_framework.renderers import JSONRenderer
|
||||||
|
|
||||||
|
|
||||||
|
# custom renderer to fix a horrible python json issue, that corrupts large integers
|
||||||
|
# issue example: parsing an integer 1204426362794811453 will corrupt the integer into 1204426362794811400.
|
||||||
|
class FixedJSONRenderer(JSONRenderer):
|
||||||
|
def handle_large_ints(self, data):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
key: self.handle_large_ints(value) # Recursively apply to nested dicts
|
||||||
|
if isinstance(value, (dict, list)) else str(value)
|
||||||
|
if isinstance(value, int) and value > 1000000000000000 else value
|
||||||
|
for key, value in data.items()
|
||||||
|
}
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [
|
||||||
|
self.handle_large_ints(item) # Recursively apply to lists
|
||||||
|
if isinstance(item, (dict, list)) else str(item)
|
||||||
|
if isinstance(item, int) and item > 1000000000000000 else item
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
|
def render(self, data, *args, **kwargs):
|
||||||
|
data = self.handle_large_ints(data) # Preprocess data to convert large ints
|
||||||
|
return super().render(data, *args, **kwargs)
|
@ -1,15 +1,26 @@
|
|||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
|
from apps.home.models import (
|
||||||
|
Server,
|
||||||
|
ContentFilter,
|
||||||
|
MessageMutator,
|
||||||
|
MessageStyle,
|
||||||
|
UniqueContentRule,
|
||||||
|
DiscordChannel,
|
||||||
|
Subscription,
|
||||||
|
Content,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# region Dynamic Model
|
||||||
|
|
||||||
# This DynamicModelSerializer is from a StackOverflow user in an obscure thread.
|
# This DynamicModelSerializer is from a StackOverflow user in an obscure thread.
|
||||||
# I wish that I could remember which thread, because god bless that man.
|
# I wish that I could remember which thread, because god bless that man.
|
||||||
|
|
||||||
@ -109,108 +120,224 @@ class DynamicModelSerializer(serializers.ModelSerializer):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class SubChannelSerializer(DynamicModelSerializer):
|
# region Servers
|
||||||
"""
|
|
||||||
Serializer for SubChannel Model.
|
class ServerSerializer(DynamicModelSerializer):
|
||||||
"""
|
id = serializers.CharField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SubChannel
|
model = Server
|
||||||
fields = ("id", "channel_id", "channel_name", "subscription")
|
fields = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"icon_hash",
|
||||||
|
"is_bot_operational",
|
||||||
|
"active"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FilterSerializer(DynamicModelSerializer):
|
# region Filters
|
||||||
"""
|
|
||||||
Serializer for the Filter Model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
class ContentFilterSerializer(DynamicModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Filter
|
model = ContentFilter
|
||||||
fields = ("id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id")
|
fields = (
|
||||||
|
"id",
|
||||||
|
"server",
|
||||||
|
"name",
|
||||||
|
"match",
|
||||||
|
"matching_algorithm",
|
||||||
|
"is_insensitive",
|
||||||
|
"is_whitelist"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ArticleMutatorSerializer(DynamicModelSerializer):
|
# region Msg Mutators
|
||||||
|
|
||||||
|
class MessageMutatorSerializer(DynamicModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ArticleMutator
|
model = MessageMutator
|
||||||
fields = ("id", "name", "value")
|
fields = ("id", "name", "value")
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionSerializer_GET(DynamicModelSerializer):
|
# region Msg Styles
|
||||||
"""
|
|
||||||
Serializer for the Subscription Model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
article_title_mutators = ArticleMutatorSerializer(many=True)
|
class MessageStyleSerializer(DynamicModelSerializer):
|
||||||
article_desc_mutators = ArticleMutatorSerializer(many=True)
|
title_mutator_detail = serializers.SerializerMethodField()
|
||||||
active = serializers.BooleanField(initial=True)
|
description_mutator_detail = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MessageStyle
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"server",
|
||||||
|
"name",
|
||||||
|
"is_embed",
|
||||||
|
"colour",
|
||||||
|
"is_hyperlinked",
|
||||||
|
"show_author",
|
||||||
|
"show_timestamp",
|
||||||
|
"show_images",
|
||||||
|
"fetch_images",
|
||||||
|
"title_mutator",
|
||||||
|
"title_mutator_detail",
|
||||||
|
"description_mutator",
|
||||||
|
"description_mutator_detail",
|
||||||
|
"auto_created"
|
||||||
|
)
|
||||||
|
read_only_fields = ("auto_created",)
|
||||||
|
|
||||||
|
def get_title_mutator_detail(self, obj: MessageStyle):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.method == "GET":
|
||||||
|
return MessageMutatorSerializer(obj.title_mutator).data
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_description_mutator_detail(self, obj: MessageStyle):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.method == "GET":
|
||||||
|
return MessageMutatorSerializer(obj.description_mutator).data
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# region Rules
|
||||||
|
|
||||||
|
class UniqueContentRuleSerializer(DynamicModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = UniqueContentRule
|
||||||
|
fields = ("id", "name", "value")
|
||||||
|
|
||||||
|
|
||||||
|
# region Subscriptions
|
||||||
|
|
||||||
|
class DiscordChannelField(serializers.PrimaryKeyRelatedField):
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
try:
|
||||||
|
data = int(data)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.fail("invalid", pk_value=data)
|
||||||
|
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return str(value.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class NestedDiscordChannelSerializer(DynamicModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DiscordChannel
|
||||||
|
fields = ("id", "name", "is_nsfw")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionSerializer(DynamicModelSerializer):
|
||||||
|
filters = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=ContentFilter.objects.all(),
|
||||||
|
many=True
|
||||||
|
)
|
||||||
|
channels = DiscordChannelField(
|
||||||
|
queryset=DiscordChannel.objects.all(),
|
||||||
|
many=True,
|
||||||
|
required=True,
|
||||||
|
allow_empty=False
|
||||||
|
)
|
||||||
|
channels_detail = serializers.SerializerMethodField()
|
||||||
|
filters_detail = serializers.SerializerMethodField()
|
||||||
|
message_style = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=MessageStyle.objects.all(),
|
||||||
|
required=True,
|
||||||
|
allow_null=False
|
||||||
|
)
|
||||||
|
message_style_detail = serializers.SerializerMethodField()
|
||||||
|
unique_rules_detail = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Subscription
|
model = Subscription
|
||||||
fields = (
|
fields = (
|
||||||
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
|
"id",
|
||||||
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
|
"server",
|
||||||
|
"name",
|
||||||
|
"url",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"extra_notes",
|
||||||
|
"active",
|
||||||
|
"publish_threshold",
|
||||||
|
"channels",
|
||||||
|
"channels_detail",
|
||||||
|
"filters",
|
||||||
|
"filters_detail",
|
||||||
|
"message_style",
|
||||||
|
"message_style_detail",
|
||||||
|
"unique_rules",
|
||||||
|
"unique_rules_detail"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_channels_detail(self, obj: Subscription):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request.method == "GET":
|
||||||
|
return NestedDiscordChannelSerializer(obj.channels.all(), many=True).data
|
||||||
|
return []
|
||||||
|
|
||||||
class SubscriptionSerializer_POST(DynamicModelSerializer):
|
def get_filters_detail(self, obj: Subscription):
|
||||||
"""
|
request = self.context.get("request")
|
||||||
Serializer for the Subscription Model.
|
if request.method == "GET":
|
||||||
"""
|
return ContentFilterSerializer(obj.filters.all(), many=True).data
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_message_style_detail(self, obj: Subscription):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request.method == "GET":
|
||||||
|
return MessageStyleSerializer(obj.message_style).data
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_unique_rules_detail(self, obj: Subscription):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request.method == "GET":
|
||||||
|
return UniqueContentRuleSerializer(obj.unique_rules.all(), many=True).data
|
||||||
|
return []
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
server = data.get("server") or self.context.get("server")
|
||||||
|
if not server:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Enforce max subscriptions per server
|
||||||
|
subscriptions_count = Subscription.objects.filter(server=server).count();
|
||||||
|
if subscriptions_count >= settings.MAX_SUBSCRIPTIONS_PER_SERVER:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Cannot create more than {settings.MAX_SUBSCRIPTIONS_PER_SERVER} subscriptions for this server."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prevent using filters from a different server
|
||||||
|
selected_filters = data.get("filters", [])
|
||||||
|
valid_filter_ids = ContentFilter.objects.filter(server=server).values_list("id", flat=True)
|
||||||
|
if any(fltr.id not in valid_filter_ids for fltr in selected_filters):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"filters": "All filters must belong to the specified server."}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prevent using message styles from a different server
|
||||||
|
message_style = data.get("message_style")
|
||||||
|
if message_style and message_style.server != server:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"message_style": "Message style must belong to the specified server."}
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# region Content
|
||||||
|
|
||||||
|
class ContentSerializer(DynamicModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Subscription
|
model = Content
|
||||||
fields = (
|
fields = (
|
||||||
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
|
"id",
|
||||||
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
|
"subscription",
|
||||||
|
"item_id",
|
||||||
|
"item_guid",
|
||||||
|
"item_url",
|
||||||
|
"item_title",
|
||||||
|
"item_content_hash"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SavedGuildSerializer(DynamicModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for the SavedGuild model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = SavedGuilds
|
|
||||||
fields = ("id", "guild_id", "name", "icon", "added_by", "permissions", "owner")
|
|
||||||
|
|
||||||
|
|
||||||
class GuildSettingsSerializer(DynamicModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for the GuildSettings model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = GuildSettings
|
|
||||||
fields = ("id", "guild_id", "default_embed_colour", "active")
|
|
||||||
|
|
||||||
|
|
||||||
class TrackedContentSerializer_GET(DynamicModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for the TrackedContent model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
subscription = SubscriptionSerializer_GET()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TrackedContent
|
|
||||||
fields = ("id", "guid", "title", "url", "subscription", "channel_id", "message_id", "blocked", "creation_datetime")
|
|
||||||
|
|
||||||
# def to_representation(self, instance):
|
|
||||||
# representation = super().to_representation(instance)
|
|
||||||
# log.info(representation.get("guid", "nothing"))
|
|
||||||
# if 'guid' in representation:
|
|
||||||
# representation['guid'] = unquote(representation['guid'])
|
|
||||||
# log.info(representation.get("guid", "nothing"))
|
|
||||||
# return representation
|
|
||||||
|
|
||||||
|
|
||||||
class TrackedContentSerializer_POST(DynamicModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for the TrackedContent model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TrackedContent
|
|
||||||
fields = ("id", "guid", "title", "url", "subscription", "channel_id", "message_id", "blocked", "creation_datetime")
|
|
||||||
|
@ -4,62 +4,65 @@ from django.urls import path, include
|
|||||||
from rest_framework.authtoken.views import obtain_auth_token
|
from rest_framework.authtoken.views import obtain_auth_token
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
SubChannel_ListView,
|
Server_ListView,
|
||||||
SubChannel_DetailView,
|
Server_DetailView,
|
||||||
Filter_ListView,
|
ContentFilter_ListView,
|
||||||
Filter_DetailView,
|
ContentFilter_DetailView,
|
||||||
|
MessageMutator_ListView,
|
||||||
|
MessageMutator_DetailView,
|
||||||
|
MessageStyle_ListView,
|
||||||
|
MessageStyle_DetailView,
|
||||||
Subscription_ListView,
|
Subscription_ListView,
|
||||||
Subscription_DetailView,
|
Subscription_DetailView,
|
||||||
Subscription_SubChannelView,
|
Content_ListView,
|
||||||
SavedGuild_ListView,
|
Content_DetailView,
|
||||||
SavedGuild_DetailView,
|
UniqueContentRule_ListView,
|
||||||
TrackedContent_ListView,
|
UniqueContentRule_DetailView
|
||||||
TrackedContent_DetailView,
|
|
||||||
ArticleMutator_ListView,
|
|
||||||
ArticleMutator_DetailView,
|
|
||||||
GuildSettings_ListView,
|
|
||||||
GuildSettings_DetailView
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
|
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
|
||||||
path("api-token-auth/", obtain_auth_token),
|
path("api-token-auth/", obtain_auth_token),
|
||||||
|
|
||||||
path("subchannel/", include([
|
# region Servers
|
||||||
path("", SubChannel_ListView.as_view(), name="subchannel"),
|
path("servers/", include([
|
||||||
path("<str:pk>/", SubChannel_DetailView.as_view(), name="subchannel-detail")
|
path("", Server_ListView.as_view()),
|
||||||
|
path("<int:pk>/", Server_DetailView.as_view())
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("filter/", include([
|
# region Filters
|
||||||
path("", Filter_ListView.as_view(), name="filter"),
|
path("filters/", include([
|
||||||
path("<str:pk>/", Filter_DetailView.as_view(), name="filter-detail")
|
path("", ContentFilter_ListView.as_view()),
|
||||||
|
path("<int:pk>/", ContentFilter_DetailView.as_view())
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("subscription/", include([
|
# region Message Mutators
|
||||||
path("", Subscription_ListView.as_view(), name="subscription"),
|
path("message-mutators/", include([
|
||||||
path("<str:pk>/", include([
|
path("", MessageMutator_ListView.as_view()),
|
||||||
path("", Subscription_DetailView.as_view(), name="subscription-detail"),
|
path("<int:pk>/", MessageMutator_DetailView.as_view())
|
||||||
path("subchannels/", Subscription_SubChannelView.as_view(), name="subscription-channels")
|
|
||||||
]))
|
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("saved-guilds/", include([
|
# region Message Styles
|
||||||
path("", SavedGuild_ListView.as_view(), name="saved-guilds"),
|
path("message-styles/", include([
|
||||||
path("<int:pk>/", SavedGuild_DetailView.as_view(), name="saved-guilds-detail")
|
path("", MessageStyle_ListView.as_view()),
|
||||||
|
path("<int:pk>/", MessageStyle_DetailView.as_view())
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("guild-settings/", include([
|
# region Subscriptions
|
||||||
path("", GuildSettings_ListView.as_view(), name="guild-settings"),
|
path("subscriptions/", include([
|
||||||
path("<int:pk>/", GuildSettings_DetailView.as_view(), name="guild-settings-detail")
|
path("", Subscription_ListView.as_view()),
|
||||||
|
path("<int:pk>/", Subscription_DetailView.as_view())
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("tracked-content/", include([
|
# region Content
|
||||||
path("", TrackedContent_ListView.as_view(), name="tracked-content"),
|
path("content/", include([
|
||||||
path("<path:pk>/", TrackedContent_DetailView.as_view(), name="tracked-content-detail")
|
path("", Content_ListView.as_view()),
|
||||||
|
path("<int:pk>/", Content_DetailView.as_view())
|
||||||
])),
|
])),
|
||||||
|
|
||||||
path("article-mutator/", include([
|
# region Unique Rules
|
||||||
path("", ArticleMutator_ListView.as_view(), name="article-mutator"),
|
path("unique-content-rules/", include([
|
||||||
path("<int:pk>/", ArticleMutator_DetailView.as_view(), name="article-mutator-detail")
|
path("", UniqueContentRule_ListView.as_view()),
|
||||||
])),
|
path("<int:pk>/", UniqueContentRule_DetailView.as_view())
|
||||||
|
]))
|
||||||
]
|
]
|
||||||
|
@ -2,29 +2,34 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.db.models import Subquery
|
|
||||||
from django.db.utils import IntegrityError
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from rest_framework import status, permissions, filters, generics
|
from rest_framework import permissions, filters, generics
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
|
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
|
|
||||||
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
|
from apps.home.models import (
|
||||||
from apps.authentication.models import DiscordUser
|
Server,
|
||||||
|
ContentFilter,
|
||||||
|
MessageMutator,
|
||||||
|
MessageStyle,
|
||||||
|
Subscription,
|
||||||
|
Content,
|
||||||
|
UniqueContentRule
|
||||||
|
)
|
||||||
|
from apps.authentication.models import DiscordUser, ServerMember
|
||||||
from .metadata import ExpandedMetadata
|
from .metadata import ExpandedMetadata
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
SubChannelSerializer,
|
ServerSerializer,
|
||||||
FilterSerializer,
|
ContentFilterSerializer,
|
||||||
SubscriptionSerializer_GET,
|
MessageMutatorSerializer,
|
||||||
SubscriptionSerializer_POST,
|
MessageStyleSerializer,
|
||||||
SavedGuildSerializer,
|
SubscriptionSerializer,
|
||||||
TrackedContentSerializer_GET,
|
ContentSerializer,
|
||||||
TrackedContentSerializer_POST,
|
UniqueContentRuleSerializer
|
||||||
ArticleMutatorSerializer,
|
|
||||||
GuildSettingsSerializer
|
|
||||||
)
|
)
|
||||||
|
from .permissions import HasServerAccess
|
||||||
|
from .errors import NotAMemberError
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,580 +46,178 @@ def is_automated_admin(user):
|
|||||||
return user.user_type == DiscordUser.USER_TYPES.AUTOMATED_USER and user.is_superuser
|
return user.user_type == DiscordUser.USER_TYPES.AUTOMATED_USER and user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================================
|
class ListView(generics.ListAPIView):
|
||||||
# SubChannel Views
|
|
||||||
|
|
||||||
class SubChannel_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of SubChannel model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
pagination_class = DefaultPagination
|
|
||||||
serializer_class = SubChannelSerializer
|
|
||||||
queryset = SubChannel.objects.all().order_by("id")
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
|
||||||
filterset_fields = ["id", "channel_id", "channel_name", "subscription"]
|
|
||||||
search_fields = ["channel_name"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return SubChannel.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class SubChannel_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to provide details on a particular SubChannel model instances.
|
|
||||||
|
|
||||||
Supports: GET, PUT, PATCH, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
serializer_class = SubChannelSerializer
|
|
||||||
queryset = SubChannel.objects.all().order_by("id")
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return SubChannel.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================================
|
|
||||||
# Filter Views
|
|
||||||
|
|
||||||
class Filter_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of Filter model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
pagination_class = DefaultPagination
|
|
||||||
serializer_class = FilterSerializer
|
|
||||||
metadata_class = ExpandedMetadata
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
|
||||||
filterset_fields = ["id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id"]
|
|
||||||
search_fields = ["name", "match"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return Filter.objects.all().order_by("id")
|
|
||||||
|
|
||||||
saved_guild_ids = SavedGuilds.objects \
|
|
||||||
.filter(added_by=self.request.user.id) \
|
|
||||||
.values("guild_id")
|
|
||||||
|
|
||||||
return Filter.objects \
|
|
||||||
.filter(guild_id__in=Subquery(saved_guild_ids)) \
|
|
||||||
.order_by("id")
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class Filter_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to provide details on a particular Filter model instances.
|
|
||||||
|
|
||||||
Supports: GET, PUT, PATCH, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
serializer_class = FilterSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return Filter.objects.all().order_by("id")
|
|
||||||
|
|
||||||
saved_guild_ids = SavedGuilds.objects \
|
|
||||||
.filter(added_by=self.request.user.id) \
|
|
||||||
.values("guild_id")
|
|
||||||
|
|
||||||
return Filter.objects \
|
|
||||||
.filter(guild_id__in=Subquery(saved_guild_ids)) \
|
|
||||||
.order_by("id")
|
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================================
|
|
||||||
# Subscription Views
|
|
||||||
|
|
||||||
class Subscription_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of Subscription model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
pagination_class = DefaultPagination
|
pagination_class = DefaultPagination
|
||||||
metadata_class = ExpandedMetadata
|
metadata_class = ExpandedMetadata
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||||
filterset_fields = [
|
|
||||||
"id", "name", "url", "guild_id", "creation_datetime", "extra_notes", "filters",
|
|
||||||
"article_title_mutators", "article_desc_mutators", "embed_colour", "published_threshold", "active"
|
|
||||||
]
|
|
||||||
search_fields = ["name", "url", "extra_notes"]
|
|
||||||
ordering_fields = ["name", "creation_datetime", "active"]
|
|
||||||
|
|
||||||
read_serializer_class = SubscriptionSerializer_GET
|
|
||||||
write_serializer_class = SubscriptionSerializer_POST
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "POST":
|
|
||||||
return self.write_serializer_class
|
|
||||||
|
|
||||||
return self.read_serializer_class
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return Subscription.objects.all().order_by("-creation_datetime")
|
|
||||||
|
|
||||||
saved_guild_ids = SavedGuilds.objects \
|
|
||||||
.filter(added_by=self.request.user.id) \
|
|
||||||
.values("guild_id")
|
|
||||||
|
|
||||||
return Subscription.objects \
|
|
||||||
.filter(guild_id__in=Subquery(saved_guild_ids)) \
|
|
||||||
.order_by("-creation_datetime")
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class Subscription_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
class ListCreateView(generics.ListCreateAPIView):
|
||||||
"""
|
|
||||||
View to provide details on a particular Subscription model instances.
|
|
||||||
|
|
||||||
Supports: GET, PUT, PATCH, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
serializer_class = SubscriptionSerializer_POST
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return Subscription.objects.all().order_by("-creation_datetime")
|
|
||||||
|
|
||||||
saved_guild_ids = SavedGuilds.objects \
|
|
||||||
.filter(added_by=self.request.user.id) \
|
|
||||||
.values("guild_id")
|
|
||||||
|
|
||||||
return Subscription.objects \
|
|
||||||
.filter(guild_id__in=Subquery(saved_guild_ids)) \
|
|
||||||
.order_by("-creation_datetime")
|
|
||||||
|
|
||||||
|
|
||||||
class Subscription_SubChannelView(generics.DestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to erase all subscription channels quickly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
subscriptions = Subscription.objects.all()
|
|
||||||
else:
|
|
||||||
saved_guild_ids = SavedGuilds.objects \
|
|
||||||
.filter(added_by=self.request.user.id) \
|
|
||||||
.values("guild_id")
|
|
||||||
|
|
||||||
subscriptions = Subscription.objects \
|
|
||||||
.filter(guild_id__in=Subquery(saved_guild_ids))
|
|
||||||
|
|
||||||
if not subscriptions:
|
|
||||||
return Response(
|
|
||||||
{"detail": "You are forbidden from viewing this subscription"},
|
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
|
||||||
|
|
||||||
subscription = subscriptions.filter(id=kwargs["pk"]).first()
|
|
||||||
|
|
||||||
if not subscription:
|
|
||||||
return Response(
|
|
||||||
{"detail": "not found"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
channels = SubChannel.objects.filter(subscription=subscription)
|
|
||||||
channels.delete();
|
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
# =================================================================================================
|
|
||||||
# SavedGuild Views
|
|
||||||
|
|
||||||
class SavedGuild_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of SavedGuild model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
pagination_class = None
|
|
||||||
serializer_class = SavedGuildSerializer
|
|
||||||
metadata_class = ExpandedMetadata
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
|
||||||
filterset_fields = ["id", "guild_id", "name", "icon", "added_by", "permissions", "owner"]
|
|
||||||
search_fields = ["name"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return SavedGuilds.objects.all()
|
|
||||||
|
|
||||||
return SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
|
|
||||||
# TODO:
|
|
||||||
# the data used for admin/owner verification is provided
|
|
||||||
# from the client, this is a potential attack vector, and
|
|
||||||
# should be rewritten.
|
|
||||||
|
|
||||||
is_owner = request.data["owner"].lower() == "true"
|
|
||||||
|
|
||||||
# Check user is admin in server
|
|
||||||
if not (self.is_server_admin(request.data["permissions"]) or is_owner):
|
|
||||||
return Response(
|
|
||||||
{"detail": "You must be a server administrator"},
|
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
|
||||||
exception=False
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
def is_server_admin(self, permissions) -> bool:
|
|
||||||
|
|
||||||
return (int(permissions) & 1 << 3) == 1 << 3
|
|
||||||
|
|
||||||
|
|
||||||
class SavedGuild_DetailView(generics.RetrieveDestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to provide details on a particular SavedGuild model instances.
|
|
||||||
|
|
||||||
Supports: GET, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
serializer_class = SavedGuildSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return SavedGuilds.objects.all()
|
|
||||||
|
|
||||||
return SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================================
|
|
||||||
# GuildSettings Views
|
|
||||||
|
|
||||||
class GuildSettings_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of GuildSettings model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
pagination_class = DefaultPagination
|
|
||||||
serializer_class = GuildSettingsSerializer
|
|
||||||
metadata_class = ExpandedMetadata
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
|
||||||
filterset_fields = ["id", "guild_id", "default_embed_colour", "active"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return GuildSettings.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return GuildSettings.objects.filter(guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=request.user)
|
|
||||||
|
|
||||||
if not saved_guilds:
|
|
||||||
return Response(
|
|
||||||
{"detail": "You must have an instance of saved guild with this guild_id"},
|
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
|
||||||
exception=False
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
def is_server_admin(self, permissions) -> bool:
|
|
||||||
|
|
||||||
return (int(permissions) & 1 << 3) == 1 << 3
|
|
||||||
|
|
||||||
|
|
||||||
class GuildSettings_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to provide details on a particular GuildSettings model instances.
|
|
||||||
|
|
||||||
Supports: GET, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
|
||||||
|
|
||||||
serializer_class = GuildSettingsSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return GuildSettings.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return GuildSettings.objects.filter(guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================================
|
|
||||||
# TrackedContent Views
|
|
||||||
|
|
||||||
class TrackedContent_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of TrackedContent model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
pagination_class = DefaultPagination
|
pagination_class = DefaultPagination
|
||||||
metadata_class = ExpandedMetadata
|
metadata_class = ExpandedMetadata
|
||||||
queryset = TrackedContent.objects.all().order_by("-creation_datetime")
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||||
filterset_fields = ["guid", "title", "url", "subscription", "subscription__guild_id", "channel_id", "blocked", "creation_datetime"]
|
|
||||||
search_fields = ["guid", "title", "url", "subscription__name", "subscription__url"]
|
|
||||||
ordering_fields = ["title", "subscription", "creation_datetime", "blocked"]
|
|
||||||
|
|
||||||
read_serializer_class = TrackedContentSerializer_GET
|
|
||||||
write_serializer_class = TrackedContentSerializer_POST
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "POST":
|
|
||||||
return self.write_serializer_class
|
|
||||||
|
|
||||||
return self.read_serializer_class
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return TrackedContent.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return TrackedContent.objects.filter(subscription__guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackedContent_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
class DetailView(generics.RetrieveAPIView):
|
||||||
"""
|
|
||||||
View to provide details on a particular TrackedContent model instances.
|
|
||||||
|
|
||||||
Supports: GET, PUT, PATCH, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
serializer_class = TrackedContentSerializer_POST
|
|
||||||
queryset = TrackedContent.objects.all().order_by("-creation_datetime")
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return TrackedContent.objects.all()
|
|
||||||
|
|
||||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
|
||||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
|
||||||
|
|
||||||
return TrackedContent.objects.filter(subscription__guild_id__in=guild_ids)
|
|
||||||
|
|
||||||
|
|
||||||
class ArticleMutator_ListView(generics.ListCreateAPIView):
|
|
||||||
"""
|
|
||||||
View to provide a list of ArticleMutator model instances.
|
|
||||||
Can also be used to create a new instance.
|
|
||||||
|
|
||||||
Supports: GET, POST
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
metadata_class = ExpandedMetadata
|
|
||||||
queryset = ArticleMutator.objects.all().order_by("id")
|
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
|
||||||
filterset_fields = ["id", "name", "value"]
|
|
||||||
|
|
||||||
serializer_class = ArticleMutatorSerializer
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.perform_create(serializer)
|
|
||||||
except IntegrityError as err:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(err)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
exception=True
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class ArticleMutator_DetailView(generics.RetrieveUpdateDestroyAPIView):
|
|
||||||
"""
|
|
||||||
View to provide details on a particular ArticleMutator model instances.
|
|
||||||
|
|
||||||
Supports: GET, PUT, PATCH, DELETE
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
class ChangableDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
serializer_class = ArticleMutatorSerializer
|
|
||||||
queryset = ArticleMutator.objects.all().order_by("id")
|
class DeletableDetailView(generics.RetrieveDestroyAPIView):
|
||||||
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
|
||||||
|
# region Servers
|
||||||
|
|
||||||
|
class Server_ListView(ListView):
|
||||||
|
filterset_fields = ("id", "name", "icon_hash", "active")
|
||||||
|
search_fields = ("name")
|
||||||
|
ordering_fields = ("id", "name", "active")
|
||||||
|
serializer_class = ServerSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return Server.objects.filter(id__in=servers).order_by("id")
|
||||||
|
|
||||||
|
|
||||||
|
class Server_DetailView(DetailView):
|
||||||
|
serializer_class = ServerSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return Server.objects.filter(id__in=servers)
|
||||||
|
|
||||||
|
|
||||||
|
# region Filters
|
||||||
|
|
||||||
|
class ContentFilter_ListView(ListCreateView):
|
||||||
|
filterset_fields = ("id", "server", "name", "match", "matching_algorithm", "is_insensitive", "is_whitelist")
|
||||||
|
search_fields = ("name", "match")
|
||||||
|
ordering_fields = ("id", "server", "name", "match", "matching_algorithm", "is_insensitive", "is_whitelist")
|
||||||
|
serializer_class = ContentFilterSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return ContentFilter.objects.filter(server__in=servers).order_by("name")
|
||||||
|
|
||||||
|
|
||||||
|
class ContentFilter_DetailView(ChangableDetailView):
|
||||||
|
serializer_class = ContentFilterSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return ContentFilter.objects.filter(server__in=servers)
|
||||||
|
|
||||||
|
|
||||||
|
# region Mutators
|
||||||
|
|
||||||
|
class MessageMutator_ListView(ListView): # instances of this one are pre-defined ONLY
|
||||||
|
filterset_fields = ("id", "name", "value")
|
||||||
|
search_fields = ("name", "value")
|
||||||
|
ordering_fields = ("id", "name", "value")
|
||||||
|
serializer_class = MessageMutatorSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return MessageMutator.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class MessageMutator_DetailView(DetailView):
|
||||||
|
serializer_class = MessageMutatorSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return MessageMutator.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
# Message Styles
|
||||||
|
|
||||||
|
class MessageStyle_ListView(ListCreateView):
|
||||||
|
filterset_fields = ("id", "server", "name", "is_embed", "is_hyperlinked", "show_author", "show_timestamp", "show_images", "fetch_images", "title_mutator", "description_mutator")
|
||||||
|
search_fields = ("name",)
|
||||||
|
ordering_fields = ("id", "server", "name", "is_embed", "is_hyperlinked", "show_author", "show_timestamp", "show_images", "fetch_images")
|
||||||
|
serializer_class = MessageStyleSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return MessageStyle.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStyle_DetailView(ChangableDetailView):
|
||||||
|
serializer_class = MessageStyleSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return MessageStyle.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
# region Subscriptions
|
||||||
|
|
||||||
|
class Subscription_ListView(ListCreateView):
|
||||||
|
filterset_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "publish_threshold", "filters", "message_style", "unique_rules")
|
||||||
|
search_fields = ("name", "url", "extra_notes")
|
||||||
|
ordering_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "message_style")
|
||||||
|
serializer_class = SubscriptionSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return Subscription.objects.filter(server__in=servers)
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription_DetailView(ChangableDetailView):
|
||||||
|
serializer_class = SubscriptionSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
return Subscription.objects.filter(server__in=servers)
|
||||||
|
|
||||||
|
|
||||||
|
# region Content
|
||||||
|
|
||||||
|
class Content_ListView(ListCreateView):
|
||||||
|
filterset_fields = ("id", "subscription", "subscription__server", "item_id", "item_guid", "item_url", "item_title", "item_content_hash")
|
||||||
|
search_fields = ("item_id", "item_guid", "item_url", "item_title", "item_content_hash")
|
||||||
|
ordering_fields = ("id", "subscription", "item_id", "item_guid", "item_url", "item_title", "item_content_hash")
|
||||||
|
serializer_class = ContentSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
subscriptions = Subscription.objects.filter(server__in=servers).values_list("id", flat=True)
|
||||||
|
return Content.objects.filter(subscription__in=subscriptions).order_by("-subscription__created_at", "id")
|
||||||
|
|
||||||
|
|
||||||
|
class Content_DetailView(ChangableDetailView):
|
||||||
|
serializer_class = ContentSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||||
|
subscriptions = Subscription.objects.filter(server__in=servers).values_list("id", flat=True)
|
||||||
|
return Content.objects.filter(subscription__in=subscriptions).order_by("-subscription__created_at", "id")
|
||||||
|
|
||||||
|
|
||||||
|
# region Unique Rules
|
||||||
|
|
||||||
|
class UniqueContentRule_ListView(ListCreateView):
|
||||||
|
filterset_fields = ("id", "name", "value")
|
||||||
|
search_fields = ("name", "value")
|
||||||
|
ordering_fields = ("id", "name", "value")
|
||||||
|
serializer_class = UniqueContentRuleSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return UniqueContentRule.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueContentRule_DetailView(ChangableDetailView):
|
||||||
|
serializer_class = UniqueContentRuleSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return UniqueContentRule.objects.all()
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import DiscordUser
|
from .models import DiscordUser, ServerMember
|
||||||
|
|
||||||
|
|
||||||
@admin.register(DiscordUser)
|
@admin.register(DiscordUser)
|
||||||
@ -10,3 +10,8 @@ class DiscordUserAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
list_display = ["id", "username", "global_name", "last_login", "is_staff", "is_superuser", "is_staff"]
|
list_display = ["id", "username", "global_name", "last_login", "is_staff", "is_superuser", "is_staff"]
|
||||||
list_filter = ["is_staff", "is_superuser", "is_active"]
|
list_filter = ["is_staff", "is_superuser", "is_active"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServerMember)
|
||||||
|
class ServerMemberAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-05-03 13:14
|
# Generated by Django 5.0.4 on 2024-09-24 14:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -10,6 +12,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
('home', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -26,6 +29,8 @@ class Migration(migrations.Migration):
|
|||||||
('mfa_enabled', models.BooleanField(help_text='whether the user has two factor enabled on their account', verbose_name='mfa enabled')),
|
('mfa_enabled', models.BooleanField(help_text='whether the user has two factor enabled on their account', verbose_name='mfa enabled')),
|
||||||
('last_login', models.DateTimeField(default=django.utils.timezone.now, help_text='datetime of the previous login', verbose_name='last login')),
|
('last_login', models.DateTimeField(default=django.utils.timezone.now, help_text='datetime of the previous login', verbose_name='last login')),
|
||||||
('access_token', models.CharField(help_text='token for the application to make api calls on behalf of the user.', max_length=100, verbose_name='access token')),
|
('access_token', models.CharField(help_text='token for the application to make api calls on behalf of the user.', max_length=100, verbose_name='access token')),
|
||||||
|
('token_expires', models.DateTimeField(help_text='when to request a new access token.', verbose_name='token expires')),
|
||||||
|
('refresh_token', models.CharField(help_text='token for the application to request a new access token.', max_length=100, verbose_name='refresh token')),
|
||||||
('user_type', models.CharField(choices=[('D', 'discord'), ('A', 'automated')], default='D', help_text='What type of user is this?', max_length=32, verbose_name='user type')),
|
('user_type', models.CharField(choices=[('D', 'discord'), ('A', 'automated')], default='D', help_text='What type of user is this?', max_length=32, verbose_name='user type')),
|
||||||
('is_active', models.BooleanField(default=True, help_text='Use as a "soft delete" rather than deleting the user.', verbose_name='active status')),
|
('is_active', models.BooleanField(default=True, help_text='Use as a "soft delete" rather than deleting the user.', verbose_name='active status')),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
@ -37,4 +42,14 @@ class Migration(migrations.Migration):
|
|||||||
'abstract': False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ServerMember',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('permissions', models.CharField(max_length=32)),
|
||||||
|
('is_owner', models.BooleanField(default=False)),
|
||||||
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-05-31 22:41
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('authentication', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='discorduser',
|
|
||||||
name='token_expires',
|
|
||||||
field=models.DateTimeField(default=timezone.now, help_text='when to request a new access token.', verbose_name='token expires'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-01 16:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('authentication', '0002_discorduser_token_expires'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='discorduser',
|
|
||||||
name='refresh_token',
|
|
||||||
field=models.CharField(default='1', help_text='token for the application to request a new access token.', max_length=100, verbose_name='refresh token'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
@ -159,3 +159,25 @@ class DiscordUser(PermissionsMixin):
|
|||||||
self.refresh_token=raw["refresh_token"]
|
self.refresh_token=raw["refresh_token"]
|
||||||
|
|
||||||
self.save(force_update=True)
|
self.save(force_update=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerMember(models.Model):
|
||||||
|
"""
|
||||||
|
A link table, connecting users to servers, and storing their server-specific data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
user = models.ForeignKey(to=DiscordUser, on_delete=models.CASCADE)
|
||||||
|
server = models.ForeignKey(to="home.Server", on_delete=models.CASCADE)
|
||||||
|
permissions = models.CharField(max_length=32)
|
||||||
|
is_owner = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.server.name} · {self.user}"
|
||||||
|
|
||||||
|
def has_permission(self, flag: int) -> bool:
|
||||||
|
"""Check that the member has a givern permission.
|
||||||
|
https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||||
|
"""
|
||||||
|
permissions_int = int(self.permissions)
|
||||||
|
return (permissions_int & flag) == flag
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %} - Login {% endblock title %}
|
{% block title %} - Login {% endblock title %}
|
||||||
@ -7,8 +7,8 @@
|
|||||||
{% block stylesheets %}{% endblock stylesheets %}
|
{% block stylesheets %}{% endblock stylesheets %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-lg px-0 h-100">
|
<div class="px-0 h-100">
|
||||||
<div class="d-flex flex-nowrap h-100 border-start border-end position-relative">
|
<div class="d-flex flex-nowrap h-100 position-relative">
|
||||||
|
|
||||||
<div class="modal d-block position-absolute">
|
<div class="modal d-block position-absolute">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
@ -3,17 +3,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.contrib.auth.views import LogoutView
|
from django.contrib.auth.views import LogoutView
|
||||||
|
|
||||||
from .views import DiscordLoginAction, DiscordLoginRedirect, Login, GuildsView, GuildChannelsView, SaveGuildView
|
from .views import DiscordLoginAction, DiscordLoginRedirect, Login
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login/", Login.as_view(), name="login"),
|
path("login/", Login.as_view(), name="login"),
|
||||||
path("oauth2/login/", DiscordLoginAction.as_view(), name="discord-login"),
|
path("oauth2/login/", DiscordLoginAction.as_view(), name="discord-login"),
|
||||||
path("oauth2/login/redirect/", DiscordLoginRedirect.as_view(), name="discord-login-redirect"),
|
path("oauth2/login/redirect/", DiscordLoginRedirect.as_view(), name="discord-login-redirect"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout")
|
||||||
|
|
||||||
path("guilds/", GuildsView.as_view(), name="guilds"),
|
|
||||||
path("channels/", GuildChannelsView.as_view(), name="channels"),
|
|
||||||
path("save-guild/", SaveGuildView.as_view(), name="save-guild")
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -49,7 +49,8 @@ class DiscordLoginRedirect(View):
|
|||||||
discord_user = authenticate(request, discord_user_data=raw_user_data)
|
discord_user = authenticate(request, discord_user_data=raw_user_data)
|
||||||
login(request, discord_user)
|
login(request, discord_user)
|
||||||
|
|
||||||
return redirect("home:index")
|
redirect_url = request.GET.get("redirect") or "home:index"
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
def exchange_code(self, code: str) -> dict:
|
def exchange_code(self, code: str) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -118,34 +119,6 @@ class Login(TemplateView):
|
|||||||
template_name = "accounts/login.html"
|
template_name = "accounts/login.html"
|
||||||
|
|
||||||
|
|
||||||
class GuildsView(View):
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
response = requests.get(
|
|
||||||
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
|
|
||||||
headers={"Authorization": f"Bearer {request.user.access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
content = response.json()
|
|
||||||
status = response.status_code
|
|
||||||
|
|
||||||
if status != 200:
|
|
||||||
log.warning("Bad status code getting guilds: %s", status)
|
|
||||||
return JsonResponse(content, safe=False, status=status)
|
|
||||||
|
|
||||||
valid_guilds = [guild for guild in response.json() if self._has_permissions(guild)]
|
|
||||||
|
|
||||||
return JsonResponse(valid_guilds, safe=False, status=status)
|
|
||||||
|
|
||||||
def _has_permissions(self, guild):
|
|
||||||
|
|
||||||
permissions = guild["permissions"]
|
|
||||||
is_owner = guild["owner"]
|
|
||||||
|
|
||||||
return (int(permissions) & 1 << 3) == 1 << 3 or is_owner
|
|
||||||
|
|
||||||
|
|
||||||
class GuildChannelsView(View):
|
class GuildChannelsView(View):
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
@ -157,9 +130,4 @@ class GuildChannelsView(View):
|
|||||||
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(response.json(), safe=False)
|
return JsonResponse(response.json(), status=response.status_code, safe=False)
|
||||||
|
|
||||||
|
|
||||||
class SaveGuildView(View):
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
@ -2,52 +2,119 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Subscription, SavedGuilds, Filter, SubChannel, TrackedContent, ArticleMutator, GuildSettings
|
from .models import (
|
||||||
|
Server,
|
||||||
|
ContentFilter,
|
||||||
|
MessageMutator,
|
||||||
|
MessageStyle,
|
||||||
|
DiscordChannel,
|
||||||
|
Subscription,
|
||||||
|
Content,
|
||||||
|
UniqueContentRule
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Server)
|
||||||
|
class ServerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"icon_hash",
|
||||||
|
"is_bot_operational",
|
||||||
|
"active"
|
||||||
|
]
|
||||||
|
list_display_links = ["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContentFilter)
|
||||||
|
class ContentFilterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"server",
|
||||||
|
"match",
|
||||||
|
"matching_algorithm",
|
||||||
|
"is_insensitive",
|
||||||
|
"is_whitelist"
|
||||||
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MessageMutator)
|
||||||
|
class MessageMutatorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"value"
|
||||||
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MessageStyle)
|
||||||
|
class MessageStyleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"server",
|
||||||
|
"is_embed",
|
||||||
|
"colour",
|
||||||
|
"is_hyperlinked",
|
||||||
|
"show_author",
|
||||||
|
"show_timestamp",
|
||||||
|
"show_images",
|
||||||
|
"fetch_images",
|
||||||
|
"title_mutator",
|
||||||
|
"description_mutator",
|
||||||
|
"auto_created"
|
||||||
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DiscordChannel)
|
||||||
|
class DiscordChannelAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"server",
|
||||||
|
"is_nsfw"
|
||||||
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Subscription)
|
@admin.register(Subscription)
|
||||||
class SubscriptionAdmin(admin.ModelAdmin):
|
class Subscription(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id", "name", "url", "guild_id",
|
"id",
|
||||||
"creation_datetime", "active"
|
"name",
|
||||||
|
"server",
|
||||||
|
"url",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"extra_notes",
|
||||||
|
"active",
|
||||||
|
"message_style"
|
||||||
]
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
|
||||||
@admin.register(SubChannel)
|
|
||||||
class SubChannelAdmin(admin.ModelAdmin):
|
@admin.register(Content)
|
||||||
|
class ContentAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id", "channel_id", "subscription"
|
"id",
|
||||||
|
"subscription",
|
||||||
|
"item_id",
|
||||||
|
"item_guid",
|
||||||
|
"item_url",
|
||||||
|
"item_title",
|
||||||
|
"item_content_hash"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Filter)
|
@admin.register(UniqueContentRule)
|
||||||
class FilterAdmin(admin.ModelAdmin):
|
class UniqueContentRule(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id", "name", "guild_id"
|
"id",
|
||||||
]
|
"name",
|
||||||
|
"value"
|
||||||
|
|
||||||
@admin.register(TrackedContent)
|
|
||||||
class TrackedContentAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
"guid", "title", "url", "subscription", "blocked", "creation_datetime"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SavedGuilds)
|
|
||||||
class SavedGuildAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
"id", "name", "icon"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ArticleMutator)
|
|
||||||
class ArticleMutatorAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
"id", "name", "value"
|
|
||||||
]
|
|
||||||
|
|
||||||
@admin.register(GuildSettings)
|
|
||||||
class GuildSettingsAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
"id", "guild_id", "default_embed_colour", "active"
|
|
||||||
]
|
]
|
||||||
|
list_display_links = ["name"]
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-17 01:11
|
# Generated by Django 5.0.4 on 2024-09-24 14:05
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -11,53 +10,70 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Filter',
|
name='MessageMutator',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=64)),
|
||||||
|
('value', models.CharField(max_length=32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Server',
|
||||||
|
fields=[
|
||||||
|
('id', models.PositiveIntegerField(primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=128)),
|
||||||
|
('icon_hash', models.CharField(blank=True, max_length=128, null=True)),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UniqueContentRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=64)),
|
||||||
|
('value', models.CharField(max_length=32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageStyle',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('is_embed', models.BooleanField(default=True)),
|
||||||
|
('is_hyperlinked', models.BooleanField()),
|
||||||
|
('show_author', models.BooleanField()),
|
||||||
|
('show_timestamp', models.BooleanField()),
|
||||||
|
('show_images', models.BooleanField()),
|
||||||
|
('fetch_images', models.BooleanField()),
|
||||||
|
('description_mutator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='desc_mutated_messagestyle', to='home.messagemutator')),
|
||||||
|
('title_mutator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='title_mutated_messagestyle', to='home.messagemutator')),
|
||||||
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContentFilter',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=32)),
|
('name', models.CharField(max_length=32)),
|
||||||
('keywords', models.CharField(blank=True, max_length=128, null=True)),
|
('match', models.CharField(max_length=256)),
|
||||||
('regex', models.CharField(blank=True, max_length=128, null=True)),
|
('matching_algorithm', models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any: Item contains any of these words (space separated)'), (2, 'All: Item contains all of these words (space separated)'), (3, 'Exact: Item contains this string'), (4, 'Regular expression: Item matches this regex'), (5, 'Fuzzy: Item contains a word similar to this word')])),
|
||||||
('whitelist', models.BooleanField(default=False)),
|
('is_insensitive', models.BooleanField()),
|
||||||
('guild_id', models.CharField(max_length=128)),
|
('is_whitelist', models.BooleanField()),
|
||||||
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||||
],
|
],
|
||||||
options={
|
|
||||||
'verbose_name': 'filter',
|
|
||||||
'verbose_name_plural': 'filters',
|
|
||||||
'get_latest_by': 'id',
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='SavedGuilds',
|
name='BotLogicLogs',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
('guild_id', models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, verbose_name='guild id')),
|
('level', models.CharField(max_length=32)),
|
||||||
('name', models.CharField(help_text='Name of the represented guild.', max_length=128)),
|
('message', models.CharField(max_length=256)),
|
||||||
('icon', models.CharField(help_text="Hash for the represented guild's icon.", max_length=128)),
|
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||||
('permissions', models.CharField(help_text='Guild permissions for the user who added this instance.', max_length=64)),
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||||
('owner', models.BooleanField(default=False, help_text="Does the 'added by' user own this guild?")),
|
|
||||||
],
|
],
|
||||||
options={
|
|
||||||
'verbose_name': 'saved guild',
|
|
||||||
'verbose_name_plural': 'saved guilds',
|
|
||||||
'get_latest_by': '-creation_datetime',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SubChannel',
|
|
||||||
fields=[
|
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
('channel_id', models.CharField(help_text='Discord snowflake ID for the represented Channel.', max_length=128, verbose_name='channel id')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'SubChannel',
|
|
||||||
'verbose_name_plural': 'SubChannels',
|
|
||||||
'get_latest_by': 'id',
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Subscription',
|
name='Subscription',
|
||||||
@ -65,76 +81,26 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=32)),
|
('name', models.CharField(max_length=32)),
|
||||||
('url', models.URLField()),
|
('url', models.URLField()),
|
||||||
('guild_id', models.CharField(max_length=128)),
|
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||||
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
('extra_notes', models.CharField(blank=True, max_length=250, null=True)),
|
('extra_notes', models.CharField(blank=True, default='', max_length=250)),
|
||||||
('uwuify', models.BooleanField(default=False)),
|
|
||||||
('active', models.BooleanField(default=True)),
|
('active', models.BooleanField(default=True)),
|
||||||
|
('filters', models.ManyToManyField(blank=True, to='home.contentfilter')),
|
||||||
|
('message_style', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='home.messagestyle')),
|
||||||
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||||
|
('unique_rules', models.ManyToManyField(to='home.uniquecontentrule')),
|
||||||
],
|
],
|
||||||
options={
|
|
||||||
'verbose_name': 'subscription',
|
|
||||||
'verbose_name_plural': 'subscriptions',
|
|
||||||
'get_latest_by': '-creation_datetime',
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TrackedContent',
|
name='Content',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
('guid', models.CharField(max_length=128)),
|
('item_id', models.CharField(max_length=1024)),
|
||||||
('title', models.CharField(max_length=128)),
|
('item_guid', models.CharField(max_length=1024)),
|
||||||
('url', models.URLField(unique=True)),
|
('item_url', models.CharField(max_length=1024)),
|
||||||
('blocked', models.BooleanField(default=False)),
|
('item_title', models.CharField(max_length=1024)),
|
||||||
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
('item_content_hash', models.CharField(max_length=1024)),
|
||||||
('guild_id', models.CharField(max_length=128)),
|
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.subscription')),
|
||||||
],
|
],
|
||||||
options={
|
|
||||||
'verbose_name': 'tracked contents',
|
|
||||||
'get_latest_by': '-creation_datetime',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='filter',
|
|
||||||
constraint=models.UniqueConstraint(fields=('name', 'guild_id'), name='unique name & guild id pair'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='savedguilds',
|
|
||||||
name='added_by',
|
|
||||||
field=models.ForeignKey(help_text='The user who added created this instance.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='added by'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='filters',
|
|
||||||
field=models.ManyToManyField(blank=True, to='home.filter'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subchannel',
|
|
||||||
name='subscription',
|
|
||||||
field=models.ForeignKey(help_text='The linked Subscription, must be unique.', on_delete=django.db.models.deletion.CASCADE, to='home.subscription'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='subscription',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.subscription'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='savedguilds',
|
|
||||||
constraint=models.UniqueConstraint(fields=('added_by', 'guild_id'), name='unique added_by & guild_id pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='subscription',
|
|
||||||
constraint=models.UniqueConstraint(fields=('name', 'guild_id'), name='unique name & server pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='subchannel',
|
|
||||||
constraint=models.UniqueConstraint(fields=('channel_id', 'subscription'), name='unique channel id and subscription pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('guid', 'guild_id'), name='unique guid & guild_id pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('url', 'guild_id'), name='unique url & guild_id pair'),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-17 01:30
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ArticleMutator',
|
|
||||||
fields=[
|
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(max_length=64)),
|
|
||||||
('value', models.CharField(max_length=32)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
47
apps/home/migrations/0002_predefined_data.py
Normal file
47
apps/home/migrations/0002_predefined_data.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-09-24 14:06
|
||||||
|
# This migration was manually configured to create predefined data by corbz
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def add_mutators(apps, schema_editor):
|
||||||
|
model = apps.get_model("home", "MessageMutator")
|
||||||
|
model.objects.create(name="Uwuify", value="uwuify")
|
||||||
|
model.objects.create(name="Uwuify (NSFW)", value="uwuify_nsfw")
|
||||||
|
model.objects.create(name="Gothic Script", value="gothic")
|
||||||
|
model.objects.create(name="Emoji Substitute", value="emj_substitute")
|
||||||
|
model.objects.create(name="Zalgo", value="zalgo")
|
||||||
|
model.objects.create(name="Morse Code", value="morse_code")
|
||||||
|
model.objects.create(name="Binary", value="to_bin")
|
||||||
|
model.objects.create(name="Hexadecimal", value="to_hex")
|
||||||
|
model.objects.create(name="Remove Vowels", value="rm_vowels")
|
||||||
|
model.objects.create(name="Double Characters", value="dbl_chars")
|
||||||
|
model.objects.create(name="Small Case", value="small_caps")
|
||||||
|
model.objects.create(name="L33t Sp34k", value="leet_speak")
|
||||||
|
model.objects.create(name="Pig Latin", value="pig_latin")
|
||||||
|
model.objects.create(name="Upside Down", value="flipped")
|
||||||
|
model.objects.create(name="All Reversed", value="reverse_text")
|
||||||
|
model.objects.create(name="Reversed Words", value="backwards_words")
|
||||||
|
model.objects.create(name="Shuffle Words", value="shuffle_words")
|
||||||
|
model.objects.create(name="Random Case", value="rnd_case")
|
||||||
|
model.objects.create(name="Gibberish", value="gibberish")
|
||||||
|
model.objects.create(name="Shakespearean", value="shakespear")
|
||||||
|
|
||||||
|
def add_rules(apps, schema_editor):
|
||||||
|
model = apps.get_model("home", "UniqueContentRule")
|
||||||
|
model.objects.create(name="GUID", value="GUID")
|
||||||
|
model.objects.create(name="ID", value="ID")
|
||||||
|
model.objects.create(name="URL", value="URL")
|
||||||
|
model.objects.create(name="Title", value="TITLE")
|
||||||
|
model.objects.create(name="Content Hash", value="CONTENT")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(add_mutators),
|
||||||
|
migrations.RunPython(add_rules)
|
||||||
|
]
|
@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-09-27 12:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0002_predefined_data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='botlogiclogs',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'bot logic log', 'verbose_name_plural': 'bot logic logs'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='content',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'content', 'verbose_name_plural': 'content'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='contentfilter',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'filter', 'verbose_name_plural': 'filters'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='messagemutator',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'message mutator', 'verbose_name_plural': 'message mutators'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='messagestyle',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'message style', 'verbose_name_plural': 'message styles'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='server',
|
||||||
|
options={'get_latest_by': 'name', 'verbose_name': 'server', 'verbose_name_plural': 'servers'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='subscription',
|
||||||
|
options={'get_latest_by': 'updated_at', 'verbose_name': 'subscription', 'verbose_name_plural': 'subscriptions'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='uniquecontentrule',
|
||||||
|
options={'get_latest_by': 'id', 'verbose_name': 'unique content rule', 'verbose_name_plural': 'unique content rules'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagestyle',
|
||||||
|
name='colour',
|
||||||
|
field=models.CharField(default='3498db', max_length=6),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='publish_threshold',
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messagestyle',
|
||||||
|
name='is_embed',
|
||||||
|
field=models.BooleanField(),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messagestyle',
|
||||||
|
name='server',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='home.server'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,37 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-17 01:23
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
def add_mutators(apps, schema_editor):
|
|
||||||
ArticleMutator = apps.get_model("home", "ArticleMutator")
|
|
||||||
ArticleMutator.objects.create(name="Uwuify", value="uwuify")
|
|
||||||
ArticleMutator.objects.create(name="Uwuify (NSFW)", value="uwuify_nsfw")
|
|
||||||
ArticleMutator.objects.create(name="Gothic Script", value="gothic")
|
|
||||||
ArticleMutator.objects.create(name="Emoji Substitute", value="emj_substitute")
|
|
||||||
ArticleMutator.objects.create(name="Zalgo", value="zalgo")
|
|
||||||
ArticleMutator.objects.create(name="Morse Code", value="morse_code")
|
|
||||||
ArticleMutator.objects.create(name="Binary", value="to_bin")
|
|
||||||
ArticleMutator.objects.create(name="Hexadecimal", value="to_hex")
|
|
||||||
ArticleMutator.objects.create(name="Remove Vowels", value="rm_vowels")
|
|
||||||
ArticleMutator.objects.create(name="Double Characters", value="dbl_chars")
|
|
||||||
ArticleMutator.objects.create(name="Small Case", value="small_caps")
|
|
||||||
ArticleMutator.objects.create(name="L33t Sp34k", value="leet_speak")
|
|
||||||
ArticleMutator.objects.create(name="Pig Latin", value="pig_latin")
|
|
||||||
ArticleMutator.objects.create(name="Upside Down", value="flipped")
|
|
||||||
ArticleMutator.objects.create(name="All Reversed", value="reverse_text")
|
|
||||||
ArticleMutator.objects.create(name="Reversed Words", value="backwards_words")
|
|
||||||
ArticleMutator.objects.create(name="Shuffle Words", value="shuffle_words")
|
|
||||||
ArticleMutator.objects.create(name="Random Case", value="rnd_case")
|
|
||||||
ArticleMutator.objects.create(name="Gibberish", value="gibberish")
|
|
||||||
ArticleMutator.objects.create(name="Shakespearean", value="shakespear")
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0002_articlemutator'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(add_mutators)
|
|
||||||
]
|
|
19
apps/home/migrations/0004_messagestyle_name.py
Normal file
19
apps/home/migrations/0004_messagestyle_name.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-09-27 15:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0003_alter_botlogiclogs_options_alter_content_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagestyle',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(default='My Message Style', max_length=32),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-17 01:31
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0003_initial_mutator_data'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='uwuify',
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-01 12:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0004_messagestyle_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DiscordChannel',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('channel_id', models.BigIntegerField()),
|
||||||
|
('name', models.CharField(max_length=128)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='channels',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='subscriptions', to='home.discordchannel'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-17 01:33
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0004_remove_subscription_uwuify'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='mutators',
|
|
||||||
field=models.ManyToManyField(blank=True, to='home.articlemutator'),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-18 11:39
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0005_subscription_mutators'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='guid',
|
|
||||||
field=models.CharField(max_length=256),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='title',
|
|
||||||
field=models.CharField(max_length=728),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-01 20:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0005_discordchannel_subscription_channels'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='name',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagestyle',
|
||||||
|
name='auto_created',
|
||||||
|
field=models.BooleanField(blank=True, default=False),
|
||||||
|
),
|
||||||
|
]
|
20
apps/home/migrations/0007_discordchannel_server.py
Normal file
20
apps/home/migrations/0007_discordchannel_server.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-02 20:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0006_remove_discordchannel_name_messagestyle_auto_created'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='server',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-25 08:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0006_alter_trackedcontent_guid_alter_trackedcontent_title'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='mutators',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_desc_mutators',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='desc_mutated_subscriptions', to='home.articlemutator'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_title_mutators',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='title_mutated_subscriptions', to='home.articlemutator'),
|
|
||||||
),
|
|
||||||
]
|
|
19
apps/home/migrations/0008_discordchannel_name.py
Normal file
19
apps/home/migrations/0008_discordchannel_name.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-02 20:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0007_discordchannel_server'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(default='placeholder name', max_length=128),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-25 09:41
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0007_remove_subscription_mutators_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_fetch_limit',
|
|
||||||
field=models.PositiveSmallIntegerField(default=10, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(10)]),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='reset_article_fetch_limit',
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-02 21:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0008_discordchannel_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='channel_id',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='name',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='is_nsfw',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='id',
|
||||||
|
field=models.PositiveIntegerField(primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
]
|
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-06-26 13:12
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0008_subscription_article_fetch_limit_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='embed_colour',
|
|
||||||
field=models.CharField(default='3498db', max_length=6),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-02 11:12
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0009_subscription_embed_colour'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='embed_colour',
|
|
||||||
field=models.CharField(blank=True, default='3498db', max_length=6),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='url',
|
|
||||||
field=models.URLField(),
|
|
||||||
),
|
|
||||||
]
|
|
19
apps/home/migrations/0010_discordchannel_name.py
Normal file
19
apps/home/migrations/0010_discordchannel_name.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-02 21:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0009_remove_discordchannel_channel_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(default='placeholder', max_length=128),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -1,34 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-02 12:36
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0010_alter_subscription_embed_colour_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='unique guid & guild_id pair',
|
|
||||||
),
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='unique url & guild_id pair',
|
|
||||||
),
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
old_name='guild_id',
|
|
||||||
new_name='channel_id',
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('guid', 'channel_id'), name='unique guid & guild_id pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('url', 'channel_id'), name='unique url & guild_id pair'),
|
|
||||||
),
|
|
||||||
]
|
|
18
apps/home/migrations/0011_server_is_bot_operational.py
Normal file
18
apps/home/migrations/0011_server_is_bot_operational.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-11 16:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0010_discordchannel_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='server',
|
||||||
|
name='is_bot_operational',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
18
apps/home/migrations/0012_alter_server_is_bot_operational.py
Normal file
18
apps/home/migrations/0012_alter_server_is_bot_operational.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-11 16:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0011_server_is_bot_operational'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='server',
|
||||||
|
name='is_bot_operational',
|
||||||
|
field=models.BooleanField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -1,34 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-05 13:29
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0011_remove_trackedcontent_unique_guid_guild_id_pair_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='unique guid & guild_id pair',
|
|
||||||
),
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='unique url & guild_id pair',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_fetch_image',
|
|
||||||
field=models.BooleanField(default=True, help_text='Will the resulting article have an image?'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('guid', 'channel_id'), name='unique guid & channel_id pair'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
constraint=models.UniqueConstraint(fields=('url', 'channel_id'), name='unique url & channel_id pair'),
|
|
||||||
),
|
|
||||||
]
|
|
18
apps/home/migrations/0013_alter_subscription_channels.py
Normal file
18
apps/home/migrations/0013_alter_subscription_channels.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-14 22:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0012_alter_server_is_bot_operational'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='channels',
|
||||||
|
field=models.ManyToManyField(related_name='subscriptions', to='home.discordchannel'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-10 09:08
|
|
||||||
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0012_remove_trackedcontent_unique_guid_guild_id_pair_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='published_theshold',
|
|
||||||
field=models.DateField(blank=True, default=django.utils.timezone.now),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-14 23:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0013_alter_subscription_channels'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='discordchannel',
|
||||||
|
name='id',
|
||||||
|
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='server',
|
||||||
|
name='id',
|
||||||
|
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
]
|
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-10 09:27
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0013_subscription_published_theshold'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='subscription',
|
|
||||||
old_name='published_theshold',
|
|
||||||
new_name='published_threshold',
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,67 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-10 13:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0014_rename_published_theshold_subscription_published_threshold'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='filter',
|
|
||||||
options={'ordering': ('name',)},
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='filter',
|
|
||||||
name='keywords',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='filter',
|
|
||||||
name='regex',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='filter',
|
|
||||||
name='whitelist',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_fetch_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='reset_article_fetch_limit',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='filter',
|
|
||||||
name='is_insensitive',
|
|
||||||
field=models.BooleanField(default=True, verbose_name='is insensitive'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='filter',
|
|
||||||
name='is_whitelist',
|
|
||||||
field=models.BooleanField(default=False, verbose_name='is whitelist'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='filter',
|
|
||||||
name='match',
|
|
||||||
field=models.CharField(blank=True, max_length=256, verbose_name='match'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='filter',
|
|
||||||
name='matching_algorithm',
|
|
||||||
field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any word'), (2, 'All words'), (3, 'Exact match'), (4, 'Regular expression'), (5, 'Fuzzy word'), (6, 'Automatic')], default=1, verbose_name='matching algorithm'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='filter',
|
|
||||||
name='guild_id',
|
|
||||||
field=models.CharField(max_length=128, verbose_name='guild id'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='filter',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(max_length=128, verbose_name='name'),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-10-15 18:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0014_alter_discordchannel_id_alter_server_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='message_style',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='home.messagestyle'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-10 19:19
|
|
||||||
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0015_alter_filter_options_remove_filter_keywords_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='filter',
|
|
||||||
name='matching_algorithm',
|
|
||||||
field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any: Item contains any of these words (space separated)'), (2, 'All: Item contains all of these words (space separated)'), (3, 'Exact: Item contains this string'), (4, 'Regular expression: Item matches this regex'), (5, 'Fuzzy: Item contains a word similar to this word')], default=1, verbose_name='matching algorithm'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='published_threshold',
|
|
||||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-11 21:59
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0016_alter_filter_matching_algorithm_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='message_id',
|
|
||||||
field=models.CharField(max_length=128),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-20 18:14
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0017_trackedcontent_message_id'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subchannel',
|
|
||||||
name='channel_name',
|
|
||||||
field=models.CharField(default='placeholder-channel-name', help_text='Name of the represented Channel.', max_length=256, verbose_name='channel name'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-23 14:28
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0018_subchannel_channel_name'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='savedguilds',
|
|
||||||
name='default_embed_colour',
|
|
||||||
field=models.CharField(blank=True, default='3498db', max_length=6),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-07-23 15:41
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0019_savedguilds_default_embed_colour'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GuildSettings',
|
|
||||||
fields=[
|
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
('guild_id', models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, verbose_name='guild id')),
|
|
||||||
('default_embed_colour', models.CharField(blank=True, default='3498db', max_length=6, verbose_name='default embed colour')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,122 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-08-14 20:41
|
|
||||||
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0020_guildsettings'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='guildsettings',
|
|
||||||
options={'get_latest_by': 'id', 'verbose_name': 'guild settings', 'verbose_name_plural': 'guild settings'},
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='savedguilds',
|
|
||||||
name='default_embed_colour',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='guildsettings',
|
|
||||||
name='active',
|
|
||||||
field=models.BooleanField(default=True, help_text='Subscriptions of inactive guilds will also be treated as inactive', verbose_name='Active'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='guildsettings',
|
|
||||||
name='guild_id',
|
|
||||||
field=models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, unique=True, verbose_name='guild id'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='savedguilds',
|
|
||||||
name='id',
|
|
||||||
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='active',
|
|
||||||
field=models.BooleanField(default=True, verbose_name='Active'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='article_fetch_image',
|
|
||||||
field=models.BooleanField(default=True, help_text='Will the resulting article have an image?', verbose_name='Fetch Article Images'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='creation_datetime',
|
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='embed_colour',
|
|
||||||
field=models.CharField(blank=True, default='3498db', max_length=6, verbose_name='Embed Colour'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='extra_notes',
|
|
||||||
field=models.CharField(blank=True, max_length=250, null=True, verbose_name='Extra Notes'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='guild_id',
|
|
||||||
field=models.CharField(max_length=128, verbose_name='Guild ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(max_length=32, verbose_name='Name'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='published_threshold',
|
|
||||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, verbose_name='Published Threshold'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='url',
|
|
||||||
field=models.URLField(verbose_name='URL'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='blocked',
|
|
||||||
field=models.BooleanField(default=False, verbose_name='Blocked'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='channel_id',
|
|
||||||
field=models.CharField(max_length=128, verbose_name='Channel ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='creation_datetime',
|
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='guid',
|
|
||||||
field=models.CharField(help_text='RSS provided GUID of the content', max_length=256, verbose_name='GUID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='id',
|
|
||||||
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='message_id',
|
|
||||||
field=models.CharField(max_length=128, verbose_name='Message ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='title',
|
|
||||||
field=models.CharField(max_length=728, verbose_name='Title'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='trackedcontent',
|
|
||||||
name='url',
|
|
||||||
field=models.URLField(verbose_name='URL'),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.0.4 on 2024-08-14 20:46
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def create_missing_guild_settings(apps, scheme_editor):
|
|
||||||
SavedGuilds = apps.get_model("home", "SavedGuilds")
|
|
||||||
GuildSettings = apps.get_model("home", "GuildSettings")
|
|
||||||
|
|
||||||
for saved_guild in SavedGuilds.objects.all():
|
|
||||||
GuildSettings.objects.get_or_create(guild_id=saved_guild.guild_id)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('home', '0021_alter_guildsettings_options_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(create_missing_guild_settings),
|
|
||||||
]
|
|
@ -1,179 +1,55 @@
|
|||||||
# -*- encoding: utf-8 -*-
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GuildSettings(models.Model):
|
# region Server
|
||||||
|
|
||||||
|
class Server(models.Model):
|
||||||
"""
|
"""
|
||||||
Represents settings for a saved Discord Guild `SavedGuild`.
|
Represents a Discord Server.
|
||||||
These objects aren't linked through foreignkey because
|
Instances of this model are automatically handled, and manual intervension
|
||||||
`SavedGuild` is user user unique, not Discord Guild unique.
|
should be avoided if possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.PositiveBigIntegerField(primary_key=True)
|
||||||
|
name = models.CharField(max_length=128)
|
||||||
guild_id = models.CharField(
|
icon_hash = models.CharField(max_length=128, blank=True, null=True)
|
||||||
verbose_name=_("guild id"),
|
is_bot_operational = models.BooleanField(default=None, null=True)
|
||||||
max_length=128,
|
active = models.BooleanField(default=True)
|
||||||
help_text=_("Discord snowflake ID for the represented guild."),
|
|
||||||
unique=True
|
|
||||||
)
|
|
||||||
|
|
||||||
default_embed_colour = models.CharField(
|
|
||||||
verbose_name=_("default embed colour"),
|
|
||||||
max_length=6,
|
|
||||||
default="3498db",
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
active = models.BooleanField(
|
|
||||||
verbose_name=_("Active"),
|
|
||||||
default=True,
|
|
||||||
help_text=_("Subscriptions of inactive guilds will also be treated as inactive")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""
|
verbose_name = "server"
|
||||||
Metadata for the GuildSettings model.
|
verbose_name_plural = "servers"
|
||||||
"""
|
get_latest_by = "name"
|
||||||
|
|
||||||
verbose_name = "guild settings"
|
|
||||||
verbose_name_plural = "guild settings"
|
|
||||||
get_latest_by = "id"
|
|
||||||
|
|
||||||
|
|
||||||
class SavedGuilds(models.Model):
|
|
||||||
"""
|
|
||||||
Represents a saved Discord Guild (aka Server).
|
|
||||||
These are shown in the UI on the sidebar, and can be selected
|
|
||||||
to see associated Subscriptions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id = models.AutoField(_("ID"), primary_key=True)
|
|
||||||
|
|
||||||
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
|
|
||||||
# issue that rounds down the value
|
|
||||||
# https://github.com/sequelize/sequelize/issues/9335
|
|
||||||
guild_id = models.CharField(
|
|
||||||
verbose_name=_("guild id"),
|
|
||||||
max_length=128,
|
|
||||||
help_text=_("Discord snowflake ID for the represented guild.")
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=128,
|
|
||||||
help_text=_("Name of the represented guild.")
|
|
||||||
)
|
|
||||||
|
|
||||||
icon = models.CharField(
|
|
||||||
max_length=128,
|
|
||||||
help_text=_("Hash for the represented guild's icon.")
|
|
||||||
)
|
|
||||||
|
|
||||||
added_by = models.ForeignKey(
|
|
||||||
verbose_name=_("added by"),
|
|
||||||
to="authentication.DiscordUser",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
help_text=_("The user who added created this instance.")
|
|
||||||
)
|
|
||||||
|
|
||||||
permissions = models.CharField(
|
|
||||||
max_length=64,
|
|
||||||
help_text=_("Guild permissions for the user who added this instance.")
|
|
||||||
)
|
|
||||||
|
|
||||||
owner = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_("Does the 'added by' user own this guild?")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""
|
|
||||||
Metadata for the SavedGuilds Model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
verbose_name = "saved guild"
|
|
||||||
verbose_name_plural = "saved guilds"
|
|
||||||
get_latest_by = "-creation_datetime"
|
|
||||||
|
|
||||||
constraints = [
|
|
||||||
# Prevent servers from having subscriptions with duplicate names
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["added_by", "guild_id"],
|
|
||||||
name="unique added_by & guild_id pair"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings(self):
|
def icon_url(self):
|
||||||
return GuildSettings.objects.get(guild_id=self.guild_id)
|
return f"https://cdn.discordapp.com/icons/{self.id}/{self.icon_hash}.webp?size=80"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def __str__(self):
|
||||||
GuildSettings.objects.get_or_create(guild_id=self.guild_id)
|
return self.name
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class SubChannel(models.Model):
|
# region Content Filter
|
||||||
|
|
||||||
|
class ContentFilter(models.Model):
|
||||||
"""
|
"""
|
||||||
Represents a Discord TextChannel, saved against a Subscription.
|
Filters for the content produced by Subscriptions.
|
||||||
SubChannels are used as targets to send content from Subscriptions.
|
Owned by the related server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
|
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
|
||||||
|
|
||||||
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
|
|
||||||
# issue that rounds down the value
|
|
||||||
# https://github.com/sequelize/sequelize/issues/9335
|
|
||||||
channel_id = models.CharField(
|
|
||||||
verbose_name=_("channel id"),
|
|
||||||
max_length=128,
|
|
||||||
help_text=_("Discord snowflake ID for the represented Channel.")
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_name = models.CharField(
|
|
||||||
verbose_name=_("channel name"),
|
|
||||||
max_length=256,
|
|
||||||
help_text=_("Name of the represented Channel.")
|
|
||||||
)
|
|
||||||
|
|
||||||
subscription = models.ForeignKey(
|
|
||||||
to="home.Subscription",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
help_text=_("The linked Subscription, must be unique.")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""
|
|
||||||
Metadata for the SubChannel Model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
verbose_name = "SubChannel"
|
|
||||||
verbose_name_plural = "SubChannels"
|
|
||||||
get_latest_by = "id"
|
|
||||||
constraints = [
|
|
||||||
# Prevent servers from having subscriptions with duplicate names
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["channel_id", "subscription"],
|
|
||||||
name="unique channel id and subscription pair")
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.channel_id
|
|
||||||
|
|
||||||
|
|
||||||
# using a brilliant matching model design from paperless-ngx src
|
|
||||||
class Filter(models.Model):
|
|
||||||
MATCH_NONE = 0
|
MATCH_NONE = 0
|
||||||
MATCH_ANY = 1
|
MATCH_ANY = 1
|
||||||
MATCH_ALL = 2
|
MATCH_ALL = 2
|
||||||
@ -189,196 +65,240 @@ class Filter(models.Model):
|
|||||||
(MATCH_LITERAL, _("Exact: Item contains this string")),
|
(MATCH_LITERAL, _("Exact: Item contains this string")),
|
||||||
(MATCH_REGEX, _("Regular expression: Item matches this regex")),
|
(MATCH_REGEX, _("Regular expression: Item matches this regex")),
|
||||||
(MATCH_FUZZY, _("Fuzzy: Item contains a word similar to this word")),
|
(MATCH_FUZZY, _("Fuzzy: Item contains a word similar to this word")),
|
||||||
# (MATCH_AUTO, _("Automatic")),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
name = models.CharField(max_length=32)
|
||||||
|
match = models.CharField(max_length=256, blank=False)
|
||||||
name = models.CharField(_("name"), max_length=128)
|
matching_algorithm = models.PositiveIntegerField(choices=MATCHING_ALGORITHMS)
|
||||||
|
is_insensitive = models.BooleanField()
|
||||||
match = models.CharField(_("match"), max_length=256, blank=True)
|
is_whitelist = models.BooleanField()
|
||||||
|
|
||||||
matching_algorithm = models.PositiveIntegerField(
|
|
||||||
_("matching algorithm"),
|
|
||||||
choices=MATCHING_ALGORITHMS,
|
|
||||||
default=MATCH_ANY,
|
|
||||||
)
|
|
||||||
|
|
||||||
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
|
|
||||||
|
|
||||||
is_whitelist = models.BooleanField(_("is whitelist"), default=False)
|
|
||||||
|
|
||||||
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
|
|
||||||
# issue that rounds down the value
|
|
||||||
# https://github.com/sequelize/sequelize/issues/9335
|
|
||||||
guild_id = models.CharField(_("guild id"), max_length=128)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("name",)
|
verbose_name = "filter"
|
||||||
constraints = [
|
verbose_name_plural = "filters"
|
||||||
models.UniqueConstraint(
|
get_latest_by = "id"
|
||||||
fields=["name", "guild_id"],
|
|
||||||
name="unique name & guild id pair"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.guild_id} - {self.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class Subscription(models.Model):
|
|
||||||
"""
|
|
||||||
The Subscription Model.
|
|
||||||
'Subscription' in the context of PYRSS is an RSS Feed with various settings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
_("Name"),
|
|
||||||
max_length=32,
|
|
||||||
null=False,
|
|
||||||
blank=False
|
|
||||||
)
|
|
||||||
|
|
||||||
url = models.URLField(_("URL"))
|
|
||||||
|
|
||||||
# NOTE:
|
|
||||||
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
|
|
||||||
# issue that rounds down the value
|
|
||||||
# https://github.com/sequelize/sequelize/issues/9335
|
|
||||||
guild_id = models.CharField(
|
|
||||||
_("Guild ID"),
|
|
||||||
max_length=128
|
|
||||||
)
|
|
||||||
|
|
||||||
creation_datetime = models.DateTimeField(
|
|
||||||
_("Created At"),
|
|
||||||
default=timezone.now,
|
|
||||||
editable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
extra_notes = models.CharField(
|
|
||||||
_("Extra Notes"),
|
|
||||||
max_length=250,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
filters = models.ManyToManyField(to="home.Filter", blank=True)
|
|
||||||
|
|
||||||
article_title_mutators = models.ManyToManyField(
|
|
||||||
to="home.ArticleMutator",
|
|
||||||
related_name="title_mutated_subscriptions",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
article_desc_mutators = models.ManyToManyField(
|
|
||||||
to="home.ArticleMutator",
|
|
||||||
related_name="desc_mutated_subscriptions",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
embed_colour = models.CharField(
|
|
||||||
_("Embed Colour"),
|
|
||||||
max_length=6,
|
|
||||||
default="3498db",
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
published_threshold = models.DateTimeField(_("Published Threshold"), default=timezone.now, blank=True)
|
|
||||||
|
|
||||||
article_fetch_image = models.BooleanField(
|
|
||||||
_("Fetch Article Images"),
|
|
||||||
default=True,
|
|
||||||
help_text="Will the resulting article have an image?"
|
|
||||||
)
|
|
||||||
|
|
||||||
active = models.BooleanField(_("Active"), default=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""
|
|
||||||
Metadata for the Subscription Model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
verbose_name = "subscription"
|
|
||||||
verbose_name_plural = "subscriptions"
|
|
||||||
get_latest_by = "-creation_datetime"
|
|
||||||
constraints = [
|
|
||||||
# Prevent servers from having subscriptions with duplicate names
|
|
||||||
models.UniqueConstraint(fields=["name", "guild_id"], name="unique name & server pair")
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def channels_count(self) -> int:
|
|
||||||
"""
|
|
||||||
Returns the number of 'SubChannel' objects assocaited
|
|
||||||
with this subscription.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return len(SubChannel.objects.filter(subscription=self))
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
new_text = "New " if self._state.adding else ""
|
|
||||||
log.debug("%sSubscription Saved %s", new_text, self.id)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class TrackedContent(models.Model):
|
# region Message Mutator
|
||||||
|
|
||||||
|
class MessageMutator(models.Model):
|
||||||
"""
|
"""
|
||||||
Tracked Content Model
|
Mutators to be applied via the Bot.
|
||||||
'Tracked Content' identifies articles and tracks them being sent.
|
Instances of this model are predefined via migrations.
|
||||||
This is used to ensure duplicate articles aren't sent in feeds.
|
Manual editing should be avoided at all costs!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.AutoField(_("ID"), primary_key=True)
|
|
||||||
|
|
||||||
guid = models.CharField(
|
|
||||||
_("GUID"),
|
|
||||||
max_length=256,
|
|
||||||
help_text=_("RSS provided GUID of the content")
|
|
||||||
)
|
|
||||||
|
|
||||||
title = models.CharField(_("Title"), max_length=728)
|
|
||||||
|
|
||||||
url = models.URLField(_("URL"))
|
|
||||||
|
|
||||||
subscription = models.ForeignKey(to=Subscription, on_delete=models.CASCADE)
|
|
||||||
|
|
||||||
channel_id = models.CharField(_("Channel ID"), max_length=128)
|
|
||||||
|
|
||||||
message_id = models.CharField(_("Message ID"), max_length=128)
|
|
||||||
|
|
||||||
blocked = models.BooleanField(_("Blocked"), default=False)
|
|
||||||
|
|
||||||
creation_datetime = models.DateTimeField(
|
|
||||||
_("Created At"),
|
|
||||||
default=timezone.now,
|
|
||||||
editable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
|
|
||||||
verbose_name = "tracked content"
|
|
||||||
verbose_name = "tracked contents"
|
|
||||||
get_latest_by = "-creation_datetime"
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(fields=["guid", "channel_id"], name="unique guid & channel_id pair"),
|
|
||||||
models.UniqueConstraint(fields=["url", "channel_id"], name="unique url & channel_id pair")
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
|
|
||||||
class ArticleMutator(models.Model):
|
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
name = models.CharField(max_length=64)
|
name = models.CharField(max_length=64)
|
||||||
value = models.CharField(max_length=32)
|
value = models.CharField(max_length=32)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
class Meta:
|
||||||
|
verbose_name = "message mutator"
|
||||||
|
verbose_name_plural = "message mutators"
|
||||||
|
get_latest_by = "id"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
# region Message Style
|
||||||
|
|
||||||
|
class MessageStyle(models.Model):
|
||||||
|
"""
|
||||||
|
Custom styles to be applied via the Bot.
|
||||||
|
Owned by the related server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, null=True, blank=False)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=32)
|
||||||
|
colour = models.CharField(max_length=6, default="3498db")
|
||||||
|
is_embed = models.BooleanField()
|
||||||
|
is_hyperlinked = models.BooleanField() # title only
|
||||||
|
show_author = models.BooleanField()
|
||||||
|
show_timestamp = models.BooleanField()
|
||||||
|
show_images = models.BooleanField()
|
||||||
|
fetch_images = models.BooleanField() # if not included with RSS item
|
||||||
|
|
||||||
|
title_mutator = models.ForeignKey(
|
||||||
|
to=MessageMutator,
|
||||||
|
related_name="title_mutated_messagestyle",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
description_mutator = models.ForeignKey(
|
||||||
|
to=MessageMutator,
|
||||||
|
related_name="desc_mutated_messagestyle",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_created = models.BooleanField(default=False, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "message style"
|
||||||
|
verbose_name_plural = "message styles"
|
||||||
|
get_latest_by = "id"
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
if self.auto_created:
|
||||||
|
raise ValidationError("Cannot delete 'MessageStyle' instance with 'auto_created=True'")
|
||||||
|
|
||||||
|
# If this style is being used, reset the users to the default style
|
||||||
|
default_message_style = MessageStyle.objects.get(server=self.server, auto_created=True)
|
||||||
|
Subscription.objects \
|
||||||
|
.filter(server=self.server, message_style=self) \
|
||||||
|
.update(message_style=default_message_style)
|
||||||
|
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Server)
|
||||||
|
def create_default_items(sender, instance, created, **kwargs):
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a default message style, so the user can get straight into creating subscriptions
|
||||||
|
# (subscriptions require a message style to exist)
|
||||||
|
MessageStyle.objects.create(
|
||||||
|
server=instance,
|
||||||
|
name=_("Default Message Style"),
|
||||||
|
colour="3498db",
|
||||||
|
is_embed=True,
|
||||||
|
is_hyperlinked=True,
|
||||||
|
show_author=True,
|
||||||
|
show_timestamp=True,
|
||||||
|
show_images=True,
|
||||||
|
fetch_images=True,
|
||||||
|
title_mutator=None,
|
||||||
|
description_mutator=None,
|
||||||
|
auto_created=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# region Unique Content Rule
|
||||||
|
|
||||||
|
class UniqueContentRule(models.Model):
|
||||||
|
"""
|
||||||
|
Definitions for what content should be unique
|
||||||
|
Instances of this model are predefined via migrations.
|
||||||
|
Manual editing should be avoided at all costs!
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
name = models.CharField(max_length=64)
|
||||||
|
value = models.CharField(max_length=32)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "unique content rule"
|
||||||
|
verbose_name_plural = "unique content rules"
|
||||||
|
get_latest_by = "id"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
# region Discord Channel
|
||||||
|
|
||||||
|
class DiscordChannel(models.Model):
|
||||||
|
"""
|
||||||
|
Store limited data on a relevant Channel from Discord, used to indicate
|
||||||
|
where subscriptions should send content to. Instance creation & deletion
|
||||||
|
is handled internally, when Subscriptions are modified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.PositiveBigIntegerField(primary_key=True)
|
||||||
|
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
|
||||||
|
name = models.CharField(max_length=128)
|
||||||
|
is_nsfw = models.BooleanField()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# region Subscription
|
||||||
|
|
||||||
|
class Subscription(models.Model):
|
||||||
|
"""
|
||||||
|
These represent RSSFeeds, storing relevant settings for managing them.
|
||||||
|
Owned by the related server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=32, blank=False)
|
||||||
|
url = models.URLField()
|
||||||
|
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
|
updated_at = models.DateTimeField(default=timezone.now)
|
||||||
|
extra_notes = models.CharField(max_length=250, default="", blank=True)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
publish_threshold = models.DateTimeField(default=timezone.now)
|
||||||
|
channels = models.ManyToManyField(to=DiscordChannel, related_name="subscriptions", blank=False)
|
||||||
|
filters = models.ManyToManyField(to=ContentFilter, blank=True)
|
||||||
|
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=False)
|
||||||
|
unique_rules = models.ManyToManyField(to=UniqueContentRule, blank=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "subscription"
|
||||||
|
verbose_name_plural = "subscriptions"
|
||||||
|
get_latest_by = "updated_at"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
# region Content
|
||||||
|
|
||||||
|
class Content(models.Model):
|
||||||
|
"""
|
||||||
|
Represents a processed item created from a Subscription.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
subscription = models.ForeignKey(to=Subscription, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
# 'item_' prefix is to differentiate between the internal identifiers and the stored data
|
||||||
|
item_id = models.CharField(max_length=1024)
|
||||||
|
item_guid = models.CharField(max_length=1024)
|
||||||
|
item_url = models.CharField(max_length=1024)
|
||||||
|
item_title = models.CharField(max_length=1024)
|
||||||
|
item_content_hash = models.CharField(max_length=1024)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "content"
|
||||||
|
verbose_name_plural = "content"
|
||||||
|
get_latest_by = "id"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.subscription.name} - {self.id}"
|
||||||
|
|
||||||
|
|
||||||
|
# region Bot Logic Logs
|
||||||
|
|
||||||
|
# Relevant logs from the bot logic
|
||||||
|
class BotLogicLogs(models.Model):
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
level = models.CharField(max_length=32)
|
||||||
|
message = models.CharField(max_length=256)
|
||||||
|
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "bot logic log"
|
||||||
|
verbose_name_plural = "bot logic logs"
|
||||||
|
get_latest_by = "id"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.server.name} - {self.id}"
|
||||||
|
@ -1,18 +1,20 @@
|
|||||||
$(document).ready(async function() {
|
$(document).ready(async function() {
|
||||||
await initSubscriptionTable();
|
initSubscriptionsModule();
|
||||||
await initFiltersTable();
|
initFiltersModule();
|
||||||
await initContentTable();
|
initContentModule();
|
||||||
|
initMessageStylesModule();
|
||||||
|
|
||||||
|
await loadServers();
|
||||||
$("#subscriptionsTab").click();
|
$("#subscriptionsTab").click();
|
||||||
|
|
||||||
await loadSavedGuilds();
|
|
||||||
await loadServerOptions();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on("selectedServerChange", function() {
|
$(document).on("selectedServerChange", function() {
|
||||||
$("#subscriptionsTab").click();
|
$("#subscriptionsTab").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Hex Strings
|
||||||
|
|
||||||
function genHexString(len=6) {
|
function genHexString(len=6) {
|
||||||
let output = '';
|
let output = '';
|
||||||
for (let i = 0; i < len; ++i) {
|
for (let i = 0; i < len; ++i) {
|
||||||
@ -21,6 +23,14 @@ function genHexString(len=6) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region DateTime
|
||||||
|
|
||||||
|
function isISODateTimeString(value) {
|
||||||
|
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$/;
|
||||||
|
return typeof value === 'string' && isoDatePattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
// my clone of python's datetime.strftime
|
// my clone of python's datetime.strftime
|
||||||
function formatStringDate(date, format) {
|
function formatStringDate(date, format) {
|
||||||
const padZero = (num, len) => String(num).padStart(len, "0");
|
const padZero = (num, len) => String(num).padStart(len, "0");
|
||||||
@ -76,7 +86,8 @@ function formatStringDate(date, format) {
|
|||||||
return format.replace(/%[a-zA-Z%-]/g, match => formatters[match] ? formatters[match](date) : match);
|
return format.replace(/%[a-zA-Z%-]/g, match => formatters[match] ? formatters[match](date) : match);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #region Colour Controls
|
|
||||||
|
// region Colour Controls
|
||||||
$(".colour-control-picker").on("change", function() {
|
$(".colour-control-picker").on("change", function() {
|
||||||
$(this).closest(".colour-control-group").find(".colour-control-text").val($(this).val());
|
$(this).closest(".colour-control-group").find(".colour-control-text").val($(this).val());
|
||||||
});
|
});
|
||||||
@ -116,18 +127,19 @@ $(document).ready(function() {
|
|||||||
label = $(this).attr("data-label");
|
label = $(this).attr("data-label");
|
||||||
helpText = $(this).attr("data-helptext");
|
helpText = $(this).attr("data-helptext");
|
||||||
tabIndex = parseInt($(this).attr("data-tabindex"));
|
tabIndex = parseInt($(this).attr("data-tabindex"));
|
||||||
|
dataField = $(this).attr("data-field");
|
||||||
defaultColour = $(this).attr("data-defaultcolour");
|
defaultColour = $(this).attr("data-defaultcolour");
|
||||||
defaultColour = defaultColour ? defaultColour : "#3498db"
|
defaultColour = defaultColour ? defaultColour : "#3498db";
|
||||||
|
|
||||||
$(this).replaceWith(`
|
$(this).replaceWith(`
|
||||||
<label for="${id}Picker" class="form-label">${label}</label>
|
<label for="${id}Picker" class="form-label">${label}</label>
|
||||||
<div id="${id}" class="input-group">
|
<div id="${id}" class="input-group">
|
||||||
<input type="color" name="${id}Picker" id="${id}Picker" class="form-control-color input-group-text colour-picker" tabindex="${tabIndex}">
|
<input type="color" name="${id}Picker" id="${id}Picker" class="form-control-color input-group-text colour-picker rounded-start-1" tabindex="${tabIndex}" data-default="${defaultColour}" data-field="${dataField}">
|
||||||
<input type="text" name="${id}Text" id="${id}Text" class="form-control colour-text" tabindex="${tabIndex + 1}">
|
<input type="text" name="${id}Text" id="${id}Text" class="form-control colour-text" tabindex="${tabIndex + 1}">
|
||||||
<button type="button" class="btn btn-secondary colour-reset" data-bs-toggle="tooltip" data-bs-title="Reset Colour" data-defaultcolour="${defaultColour}" tabindex="${tabIndex + 2}">
|
<button type="button" class="btn btn-secondary colour-reset" data-bs-toggle="tooltip" data-bs-title="Reset Colour" data-defaultcolour="${defaultColour}" tabindex="${tabIndex + 2}">
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary colour-random" data-bs-toggle="tooltip" data-bs-title="Random Colour" tabindex="${tabIndex + 3}">
|
<button type="button" class="btn btn-secondary rounded-end-1 colour-random" data-bs-toggle="tooltip" data-bs-title="Random Colour" tabindex="${tabIndex + 3}">
|
||||||
<i class="bi bi-dice-5"></i>
|
<i class="bi bi-dice-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -155,38 +167,94 @@ $(document).ready(function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function confirmationModal(title, bodyText, style, acceptFunc, declineFunc) {
|
|
||||||
let $modal = $("#confirmationModal");
|
|
||||||
|
|
||||||
// Ensure valid style and apply it to the confirm button
|
// region Log Error
|
||||||
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
|
|
||||||
throw new Error(`${style} is not a valid style`);
|
function logError(error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Logs typical error properties like message and stack
|
||||||
|
console.error({
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
});
|
||||||
|
} else if (typeof error === 'object' && error !== null) {
|
||||||
|
// Try to stringify if it's an object
|
||||||
|
try {
|
||||||
|
console.error(JSON.stringify(error, null, 2));
|
||||||
|
} catch (stringifyError) {
|
||||||
|
console.error('Could not stringify the error:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for any other types (string, number, etc.)
|
||||||
|
console.error('Error:', error);
|
||||||
}
|
}
|
||||||
$modal.find(".modal-confirm-btn").addClass(`btn-${style}`);
|
|
||||||
|
|
||||||
$modal.find(".modal-title").text(title);
|
|
||||||
$modal.find(".modal-body > p").html(bodyText);
|
|
||||||
|
|
||||||
$modal.find(".modal-confirm-btn").off("click").on("click", async function(e) {
|
|
||||||
await acceptFunc()
|
|
||||||
$modal.modal("hide");
|
|
||||||
});
|
|
||||||
|
|
||||||
$modal.find(".modal-dismiss-btn").off("click").on("click", async function(e) {
|
|
||||||
if (declineFunc) await declineFunc();
|
|
||||||
$modal.modal("hide");
|
|
||||||
});
|
|
||||||
|
|
||||||
$modal.modal("show");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayToHtmlList(array, bold=false) {
|
|
||||||
$ul = $("<ul>").addClass("mb-0");
|
|
||||||
|
|
||||||
array.forEach(item => {
|
// region Sidebar Visibility
|
||||||
let $li = $("<li>");
|
|
||||||
$ul.append(bold ? $li.append($("<b>").text(item)) : $li.text(item));
|
|
||||||
});
|
|
||||||
|
|
||||||
return $ul;
|
var _sidebarVisible = $(".sidebar").hasClass("visible"); // Applicable for smaller screens ONLY
|
||||||
}
|
const getSidebarVisibility = () => _sidebarVisible;
|
||||||
|
const setSidebarVisibility = show => {
|
||||||
|
// Must always show if the sidebar is pinned
|
||||||
|
show = getSidebarPinned() ? true : show;
|
||||||
|
|
||||||
|
_sidebarVisible = show;
|
||||||
|
if (show) {
|
||||||
|
$(".sidebar").addClass("visible");
|
||||||
|
$(".sidebar-backdrop").show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$(".sidebar").removeClass("visible");
|
||||||
|
$(".sidebar-backdrop").hide();
|
||||||
|
}
|
||||||
|
const toggleSidebarVisibility = () => setSidebarVisibility(!getSidebarVisibility());
|
||||||
|
|
||||||
|
// Trigger an update to set the backdrop
|
||||||
|
$(document).ready(() => setSidebarVisibility(getSidebarVisibility()));
|
||||||
|
|
||||||
|
// User controls for sidebar visibility
|
||||||
|
$(".reveal-sidebar-btn").on("click", toggleSidebarVisibility);
|
||||||
|
$(".sidebar .btn-close").on("click", () => setSidebarVisibility(false));
|
||||||
|
$(".sidebar-backdrop").on("click", () => setSidebarVisibility(false));
|
||||||
|
|
||||||
|
// Prevent sidebar from opening if the screen becomes larger then smaller again, while it's visible
|
||||||
|
$(window).on('resize', () => {
|
||||||
|
const sidebarVisibility = getSidebarVisibility();
|
||||||
|
|
||||||
|
// If the sidebar expands to be permanently visible, remove the pin & open
|
||||||
|
// effects, so that it collapses again when shrinking the screen.
|
||||||
|
//
|
||||||
|
// Can't pass conditional directly, causes flickering effect
|
||||||
|
if (sidebarVisibility && $(window).width() > 992) {
|
||||||
|
setSidebarPinned(false);
|
||||||
|
setSidebarVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the theme menu when the sidebar collapses offscreen
|
||||||
|
else if (!sidebarVisibility && $(window).width() <= 992) {
|
||||||
|
$(".js-themeMenuBtn").dropdown("hide");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Sidebar Pin
|
||||||
|
|
||||||
|
var _sidebarPinned = $(".js-pinSidebar").hasClass("active");
|
||||||
|
const getSidebarPinned = () => _sidebarPinned;
|
||||||
|
const setSidebarPinned = pin => {
|
||||||
|
_sidebarPinned = pin;
|
||||||
|
|
||||||
|
// Show button as active or not
|
||||||
|
const btn = $(".js-pinSidebar");
|
||||||
|
pin ? btn.addClass("active") : btn.removeClass("active");
|
||||||
|
|
||||||
|
$(".sidebar .btn-close").prop("disabled", pin);
|
||||||
|
$(".reveal-sidebar-btn").prop("disabled", pin);
|
||||||
|
}
|
||||||
|
const toggleSidebarPinned = () => setSidebarPinned(!getSidebarPinned());
|
||||||
|
|
||||||
|
// User controls for pinning the sidebar
|
||||||
|
$(".js-pinSidebar").on("click", toggleSidebarPinned);
|
85
apps/home/static/home/js/modals.js
Normal file
85
apps/home/static/home/js/modals.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
const validateBootstrapStyle = style => {
|
||||||
|
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
|
||||||
|
throw new Error(`${style} is not a valid style`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const arrayToHtmlList = (array, bold=false) => {
|
||||||
|
$ul = $("<ul>").addClass("mb-0");
|
||||||
|
|
||||||
|
array.forEach(item => {
|
||||||
|
let $li = $("<li>");
|
||||||
|
$ul.append(bold ? $li.append($("<b>").text(item)) : $li.text(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $ul;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const createModal = async (options = {}) => {
|
||||||
|
let $modal = $("#customModal");
|
||||||
|
|
||||||
|
let defaults = {
|
||||||
|
title: "Modal Title",
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: "This is a modal",
|
||||||
|
html: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Okay",
|
||||||
|
className: "btn-primary",
|
||||||
|
iconClass: "",
|
||||||
|
tabIndex: 2,
|
||||||
|
closeModal: true,
|
||||||
|
onClick: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = { ...defaults, ...options };
|
||||||
|
|
||||||
|
$modal.find(".modal-title").text(settings.title);
|
||||||
|
|
||||||
|
// Texts
|
||||||
|
let $body = $modal.find(".modal-body").empty();
|
||||||
|
|
||||||
|
settings.texts.forEach((text, idx) => {
|
||||||
|
if (text.html) {
|
||||||
|
$body.append(text.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let $para = $("<p>", {
|
||||||
|
text: text.content,
|
||||||
|
class: idx + 1 === settings.texts.length ? "mb-0" : ""
|
||||||
|
});
|
||||||
|
$body.append($para);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
let $footer = $modal.find(".modal-footer").empty();
|
||||||
|
|
||||||
|
settings.buttons.forEach(button => {
|
||||||
|
let $btn = $("<button>", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn rounded-1 " + button.className,
|
||||||
|
tabIndex: button.tabIndex,
|
||||||
|
html: button.iconClass ?`<i class="${button.iconClass} ${button.text ? "me-2" : ""}"></i>${button.text ? button.text : ""}` : button.text
|
||||||
|
});
|
||||||
|
|
||||||
|
$btn.on("click", async () => {
|
||||||
|
if (button.onClick) await button.onClick();
|
||||||
|
if (button.closeModal) $modal.modal("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
$footer.append($btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
$modal.modal("show");
|
||||||
|
}
|
390
apps/home/static/home/js/servers.js
Normal file
390
apps/home/static/home/js/servers.js
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
|
||||||
|
// region Loaded Servers
|
||||||
|
|
||||||
|
var _loadedServers = []
|
||||||
|
var selectedServer = null;
|
||||||
|
|
||||||
|
function getLoadedServer(options) {
|
||||||
|
let servers = _loadedServers.filter(item => {
|
||||||
|
|
||||||
|
for (let key in options) {
|
||||||
|
if (item[key] !== options[key]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return servers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerFromSnowflake(id) {
|
||||||
|
server = getLoadedServer({id: id});
|
||||||
|
if (!server.length) {
|
||||||
|
throw new Error("No Server with that ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return server[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToLoadedServers(serverData, autoSelect=false) {
|
||||||
|
_loadedServers.push(serverData);
|
||||||
|
createSelectButton(serverData);
|
||||||
|
|
||||||
|
if (autoSelect) {
|
||||||
|
selectServer(serverData["id"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromLoadedServers(id) {
|
||||||
|
_loadedServers = _loadedServers.filter(item => item.id !== id);
|
||||||
|
removeSelectButton(id)
|
||||||
|
|
||||||
|
if (selectedServer.id === id) {
|
||||||
|
selectedServer(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Loaded Channels
|
||||||
|
|
||||||
|
var _loadedChannels = {};
|
||||||
|
async function loadedChannels(serverId) {
|
||||||
|
if (!(serverId in _loadedChannels)) {
|
||||||
|
await fetchChannels(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _loadedChannels[serverId]
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
// Try load channels to determine if bot has permissions
|
||||||
|
loadedChannels(selectedServer.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchChannels = async serverId => {
|
||||||
|
$(".sidebar .sidebar-item").prop("disabled", true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
channels = await ajaxRequest(`/generate-channels?guild=${serverId}`, "GET");
|
||||||
|
_loadedChannels[serverId] = channels;
|
||||||
|
unspotItem(serverId); // remove the spot because no errors occured
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logError(error);
|
||||||
|
unspotItem(serverId);
|
||||||
|
|
||||||
|
switch (error?.status) {
|
||||||
|
case 429:
|
||||||
|
rateLimitedLoadingChannels(error.responseJSON.retry_after);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
notAuthorisedLoadingChannels(serverId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
alert("unknown error loading channels");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$(".sidebar .sidebar-item").prop("disabled", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitedLoadingChannels = retryAfterSeconds => {
|
||||||
|
createModal({
|
||||||
|
title: "Failed to Fetch Server Channels",
|
||||||
|
texts: [
|
||||||
|
{ content: "Discord is rate-limiting your request." },
|
||||||
|
{ content: `This happens when making requests too quickly. Retry after ${retryAfterSeconds} seconds to continue without issue.` }
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-warning px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notAuthorisedLoadingChannels = serverId => {
|
||||||
|
// Mark the sidebar item as non-operational
|
||||||
|
spotItem(serverId, "danger");
|
||||||
|
|
||||||
|
const inviteBotToServer = () => {
|
||||||
|
window.open(
|
||||||
|
`https://discord.com/oauth2/authorize
|
||||||
|
?client_id=${discordClientId}
|
||||||
|
&permissions=2147534848
|
||||||
|
&scope=bot+applications.commands
|
||||||
|
&guild_id=${serverId}
|
||||||
|
&disable_guild_select=true`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inform the user auth problem
|
||||||
|
createModal({
|
||||||
|
title: "Failed to Fetch Server Channels",
|
||||||
|
texts: [
|
||||||
|
{ content: "The Discord Bot is unable to access this server's channels, certain features will not work." },
|
||||||
|
{ content: "Ensure the Bot is a member, and has the neccessary permissions to operate." }
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Invite the Bot",
|
||||||
|
className: "btn-primary me-3",
|
||||||
|
iconClass: "bi-envelope-plus",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: inviteBotToServer
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region UI Buttons
|
||||||
|
|
||||||
|
function createSelectButton(serverData) {
|
||||||
|
// server details
|
||||||
|
const id = serverData["id"];
|
||||||
|
const name = serverData["name"];
|
||||||
|
const iconHash = serverData["icon_hash"];
|
||||||
|
const isBotOperational = serverData["is_bot_operational"];
|
||||||
|
|
||||||
|
let template = $($("#serverItemTemplate").html());
|
||||||
|
const imageUrl = `https://cdn.discordapp.com/icons/${id}/${iconHash}.webp?size=80`;
|
||||||
|
const altText = name.split(' ').map(word => word.charAt(0)).join(''); // initials of server name, used if iconUrl is 404
|
||||||
|
|
||||||
|
template.find(".js-image").attr("src", imageUrl).attr("alt", altText);
|
||||||
|
template.find(".js-name").text(name);
|
||||||
|
template.find(".js-id").text(id);
|
||||||
|
template.find(".sidebar-item").attr("data-id", id);
|
||||||
|
|
||||||
|
// Show inoperational status, also, `isBotOperatioanl` can be null,
|
||||||
|
// so we can't rely on it's truthy value.
|
||||||
|
if (isBotOperational === false) {
|
||||||
|
template.find(".sidebar-item").addClass("spot spot-danger");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the button for selecting this server
|
||||||
|
template.find(".sidebar-item").off("click").on("click", function() {
|
||||||
|
const myID = $(this).data("id");
|
||||||
|
|
||||||
|
// only select if not already selected, otherwise hide sidebar on smaller screens (responsive)
|
||||||
|
selectedServer?.id !== myID ? selectServer(myID) : setSidebarVisibility(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#serverList").prepend(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectButton(id) {
|
||||||
|
$(`#serverList .server-item[data-id=${id}]`).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#backToSelectServer").on("click", function() {
|
||||||
|
$("#noSelectedServer").show();
|
||||||
|
$("#selectedServerContainer").hide();
|
||||||
|
$("#serverList .server-item > .server-item-selector.active").removeClass("active");
|
||||||
|
selectedServer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// #region Server Selection
|
||||||
|
|
||||||
|
function selectServer(id) {
|
||||||
|
let server = getServerFromSnowflake(id);
|
||||||
|
if (!server) {
|
||||||
|
$("#noSelectedServer").show();
|
||||||
|
$("#selectedServerContainer").hide();
|
||||||
|
selectServer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change appearance of selected vs none-selected items
|
||||||
|
$("#serverList .sidebar-item").removeClass("active");
|
||||||
|
$(`#serverList .sidebar-item[data-id="${id}"]`).addClass("active");
|
||||||
|
|
||||||
|
// Global variable
|
||||||
|
selectedServer = server;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
$("#noSelectedServer").hide();
|
||||||
|
$("#selectedServerContainer").show().css("display", "flex");
|
||||||
|
|
||||||
|
// Close sidebar on smaller screens
|
||||||
|
setSidebarVisibility(false);
|
||||||
|
|
||||||
|
// Announce change to any listeners
|
||||||
|
$(document).trigger("selectedServerChange");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// #region Resolve Strings
|
||||||
|
|
||||||
|
function resolveServerStrings() {
|
||||||
|
// Server icon
|
||||||
|
$(".resolve-to-server-icon").attr(
|
||||||
|
"src",
|
||||||
|
`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon_hash}.webp?size=80`
|
||||||
|
).attr("alt", selectedServer.name.split(' ').map(word => word.charAt(0)).join(''));
|
||||||
|
|
||||||
|
// Server names
|
||||||
|
$(".resolve-to-server-name").text(selectedServer.name);
|
||||||
|
|
||||||
|
// Server Guild Ids
|
||||||
|
$(".resolve-to-server-id").text(selectedServer.id);
|
||||||
|
|
||||||
|
// Bot Invite links
|
||||||
|
$(".resolve-to-invite-link").attr("href", `https://discord.com/oauth2/authorize
|
||||||
|
?client_id=${discordClientId}
|
||||||
|
&permissions=2147534848
|
||||||
|
&scope=bot+applications.commands
|
||||||
|
&guild_id=${selectedServer.id}
|
||||||
|
&disable_guild_select=true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Change Listener
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
resolveServerStrings();
|
||||||
|
$("#serverJoinAlert").hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Servers
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
|
||||||
|
// Remove any previously loaded servers
|
||||||
|
$(".sidebar .sidebar-item").closest("li").remove();
|
||||||
|
|
||||||
|
// Show placeholder items & hide rate limit warning
|
||||||
|
$(".sidebar .sidebar-loading").show();
|
||||||
|
$(".sidebar .server-rate-limit").hide();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const servers = await ajaxRequest("/generate-servers/", "GET");
|
||||||
|
servers.forEach(server => addToLoadedServers(server, false));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
switch (error?.status) {
|
||||||
|
case 401:
|
||||||
|
window.location.href = "/login"; // discord token has expired
|
||||||
|
break;
|
||||||
|
case 429:
|
||||||
|
$(".sidebar .server-rate-limit").show();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logError(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$(".sidebar .sidebar-loading").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry load servers button
|
||||||
|
$(".sidebar .sidebar-retry-btn").on("click", loadServers);
|
||||||
|
|
||||||
|
|
||||||
|
// region Spot Icons
|
||||||
|
|
||||||
|
const unspotItem = id => {
|
||||||
|
$(`.sidebar .sidebar-item[data-id="${id}"]`).removeClass(
|
||||||
|
"spot spot-primary spot-secondary spot-success spot-info spot-warning spot-danger"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotItem = (id, spotStyle) => {
|
||||||
|
$(`.sidebar .sidebar-item[data-id="${id}"]`).addClass(`spot spot-${spotStyle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region View Other Users
|
||||||
|
|
||||||
|
$(".js-serverUsersBtn").on("click", () => {
|
||||||
|
createModal({
|
||||||
|
title: "Other Users",
|
||||||
|
texts: [
|
||||||
|
{content: "This feature has not yet been implemented."}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region View Edit History
|
||||||
|
|
||||||
|
$(".js-serverHistoryBtn").on("click", () => {
|
||||||
|
createModal({
|
||||||
|
title: "Edit History",
|
||||||
|
texts: [
|
||||||
|
{content: "This feature has not yet been implemented."}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Delete Server Data
|
||||||
|
|
||||||
|
$(".js-eraseServerBtn").on("click", () => {
|
||||||
|
const server = selectedServer; // Store incase it changes
|
||||||
|
const itemsToLose = arrayToHtmlList([
|
||||||
|
"Subscriptions",
|
||||||
|
"Filters",
|
||||||
|
"Message Styles",
|
||||||
|
"Tracked Content"
|
||||||
|
]).addClass("mb-3").prop("outerHTML");
|
||||||
|
|
||||||
|
const eraseServerData = () => {
|
||||||
|
alert("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: `Delete Data for ${server.name}?`,
|
||||||
|
texts: [
|
||||||
|
{content: "You will lose all data related to this server, including:"},
|
||||||
|
{content: itemsToLose, html: true},
|
||||||
|
{content: "Please reconsider this decision."}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: eraseServerData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
620
apps/home/static/home/js/tables.js
Normal file
620
apps/home/static/home/js/tables.js
Normal file
@ -0,0 +1,620 @@
|
|||||||
|
|
||||||
|
// region Init Table
|
||||||
|
|
||||||
|
function initializeDataTable(tableId, columns) {
|
||||||
|
$(tableId).DataTable({
|
||||||
|
info: false,
|
||||||
|
paging: false,
|
||||||
|
ordering: false,
|
||||||
|
searching: false,
|
||||||
|
autoWidth: false,
|
||||||
|
order: [],
|
||||||
|
language: {
|
||||||
|
emptyTable: "No results found"
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
style: "multi+shift",
|
||||||
|
selector: 'th:first-child input[type="checkbox"]'
|
||||||
|
},
|
||||||
|
columnDefs: [
|
||||||
|
{ orderable: false, targets: "no-sort" },
|
||||||
|
{
|
||||||
|
targets: 0,
|
||||||
|
checkboxes: { selectRow: true }
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
// Select row checkbox column
|
||||||
|
title: '<input type="checkbox" class="form-check-input table-select-all" />',
|
||||||
|
data: null,
|
||||||
|
orderable: false,
|
||||||
|
className: "col-checkbox text-center",
|
||||||
|
render: function() {
|
||||||
|
return '<input type="checkbox" class="form-check-input table-select-row" />'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ data: "id", visible: false },
|
||||||
|
...columns
|
||||||
|
]
|
||||||
|
});
|
||||||
|
bindTablePagination(tableId);
|
||||||
|
bindTablePageSizer(tableId);
|
||||||
|
bindTableSearch(tableId);
|
||||||
|
bindRefreshButton(tableId);
|
||||||
|
bindTableSelectColumn(tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Filters & Ordering
|
||||||
|
|
||||||
|
// Filter methods
|
||||||
|
var _tableFilters = {};
|
||||||
|
function getTableFilters(tableId) {
|
||||||
|
return _tableFilters[tableId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableFilter(tableId, key, value) {
|
||||||
|
if (!_tableFilters[tableId]) {
|
||||||
|
_tableFilters[tableId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
_tableFilters[tableId][key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort methods
|
||||||
|
var _tableOrdering = {};
|
||||||
|
function getTableOrdering(tableId) {
|
||||||
|
return _tableOrdering[tableId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableOrdering(tableId, value) {
|
||||||
|
_tableOrdering[tableId] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all kinds of sorting and filtering when changing servers
|
||||||
|
$(document).on("selectedServerChange", function() {
|
||||||
|
_tableFilters = {};
|
||||||
|
_tableOrdering = {};
|
||||||
|
$(".table-search-input").val("");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Load & Clear Data
|
||||||
|
|
||||||
|
function wipeTable(tableId) {
|
||||||
|
$(`${tableId} thead .table-select-all`).prop("checked", false).prop("indeterminate", false);
|
||||||
|
$(tableId).DataTable().clear().draw(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTable(tableId, data) {
|
||||||
|
$(tableId).DataTable().rows.add(data.results).draw(false);
|
||||||
|
updateTablePagination(tableId, data);
|
||||||
|
updateTableTotalCount(tableId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTableData(tableId, url, method) {
|
||||||
|
fixTablePagination(tableId);
|
||||||
|
disableTableControls(tableId);
|
||||||
|
|
||||||
|
// Create querystring for filtering against the API
|
||||||
|
const filters = getTableFilters(tableId);
|
||||||
|
const ordering = getTableOrdering(tableId);
|
||||||
|
const querystring = makeQuerystring(filters, ordering);
|
||||||
|
|
||||||
|
// API request
|
||||||
|
const data = await ajaxRequest(url + querystring, method);
|
||||||
|
|
||||||
|
// Update table with new data
|
||||||
|
wipeTable(tableId);
|
||||||
|
populateTable(tableId, data);
|
||||||
|
enableTableControls(tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Pagination
|
||||||
|
|
||||||
|
function bindTablePagination(tableId) {
|
||||||
|
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
|
||||||
|
$paginationArea.on("click", ".page-link", function() {
|
||||||
|
let currentPage = parseInt($paginationArea.data("page"));
|
||||||
|
let wantedPage;
|
||||||
|
|
||||||
|
if ($(this).hasClass("page-prev")) {
|
||||||
|
wantedPage = currentPage - 1;
|
||||||
|
}
|
||||||
|
else if ($(this).hasClass("page-next")) {
|
||||||
|
wantedPage = currentPage + 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
wantedPage = $(this).attr("data-page")
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableFilter(tableId, "page", wantedPage);
|
||||||
|
$(tableId).trigger("doDataLoad");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixTablePagination(tableId) {
|
||||||
|
let filters = getTableFilters(tableId);
|
||||||
|
let $pageSizer = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find(".table-page-sizer");
|
||||||
|
|
||||||
|
if (!("page" in filters)) {
|
||||||
|
setTableFilter(tableId, "page", 1);
|
||||||
|
}
|
||||||
|
if (!("page_size" in filters)) {
|
||||||
|
setTableFilter(tableId, "page_size", $($pageSizer).val())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTablePagination(tableId, data) {
|
||||||
|
let filters = getTableFilters(tableId);
|
||||||
|
|
||||||
|
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
|
||||||
|
$paginationArea.data("page", filters.page); // store the page for later
|
||||||
|
|
||||||
|
// Remove existing buttons for specific pages
|
||||||
|
$paginationArea.find(".page-pick").remove();
|
||||||
|
|
||||||
|
// Enable/disable 'next' and 'prev' buttons
|
||||||
|
$paginationArea.find(".page-prev").toggleClass("disabled", !data.previous);
|
||||||
|
$paginationArea.find(".page-next").toggleClass("disabled", !data.next);
|
||||||
|
|
||||||
|
const pagesToShow = Math.max(Math.ceil(data.count / filters.page_size), 1);
|
||||||
|
const maxPagesToShow = 10;
|
||||||
|
|
||||||
|
let startPage = 1;
|
||||||
|
let endPage;
|
||||||
|
let page = parseInt(filters.page);
|
||||||
|
|
||||||
|
// Determine the start and end page
|
||||||
|
if (pagesToShow <= maxPagesToShow) {
|
||||||
|
endPage = pagesToShow;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const halfVisible = Math.floor(maxPagesToShow / 2);
|
||||||
|
if (page <= halfVisible) {
|
||||||
|
endPage = maxPagesToShow;
|
||||||
|
}
|
||||||
|
else if (page + halfVisible >= pagesToShow) {
|
||||||
|
startPage = pagesToShow - maxPagesToShow + 1;
|
||||||
|
endPage = pagesToShow;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
startPage = page - halfVisible;
|
||||||
|
endPage = page + halfVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add buttons
|
||||||
|
if (startPage > 1) {
|
||||||
|
AddTablePageButton($paginationArea, 1);
|
||||||
|
if (startPage > 2) {
|
||||||
|
AddTablePageEllipsis($paginationArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i = startPage; i <= endPage; i++) {
|
||||||
|
AddTablePageButton($paginationArea, i, page);
|
||||||
|
}
|
||||||
|
if (endPage < pagesToShow) {
|
||||||
|
if (endPage < pagesToShow - 1) {
|
||||||
|
AddTablePageEllipsis($paginationArea);
|
||||||
|
}
|
||||||
|
AddTablePageButton($paginationArea, pagesToShow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddTablePageButton($paginationArea, number, currentPage) {
|
||||||
|
let pageItem = $("<li>").addClass("page-item");
|
||||||
|
let pageLink = $("<button>")
|
||||||
|
.attr("type", "button")
|
||||||
|
.attr("data-page", number)
|
||||||
|
.addClass("page-link page-pick")
|
||||||
|
.text(number);
|
||||||
|
|
||||||
|
if (number === parseInt(currentPage)) {
|
||||||
|
pageLink.addClass("disabled").attr("tabindex", -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItem.append(pageLink);
|
||||||
|
$paginationArea.find(".page-next").parent().before(pageItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddTablePageEllipsis($paginationArea) {
|
||||||
|
let ellipsisItem = $("<li>").addClass("page-item disabled");
|
||||||
|
let ellipsisLink = $("<span>").addClass("page-link page-pick").text("...");
|
||||||
|
ellipsisItem.append(ellipsisLink);
|
||||||
|
$paginationArea.find(".page-next").parent().before(ellipsisItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Page Sizer
|
||||||
|
|
||||||
|
function updateTableTotalCount(tableId, data) {
|
||||||
|
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
|
||||||
|
$tableControls.find('.pageinfo-total').text(data.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTablePageSizer(tableId) {
|
||||||
|
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
|
||||||
|
$tableControls.on("change", ".table-page-sizer", function() {
|
||||||
|
setTableFilter(tableId, "page", "1");
|
||||||
|
setTableFilter(tableId, "page_size", $(this).val());
|
||||||
|
$(tableId).trigger("doDataLoad");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Search Filters
|
||||||
|
|
||||||
|
_searchTimeouts = {};
|
||||||
|
function bindTableSearch(tableId) {
|
||||||
|
const $tableFilters = $(tableId).closest('.js-tableBody').siblings('.js-tableFilters');
|
||||||
|
$tableFilters.on("input", ".table-search-input", function() {
|
||||||
|
$(this).data("was-focused", true);
|
||||||
|
clearTimeout(_searchTimeouts[tableId]);
|
||||||
|
|
||||||
|
const searchString = $(this).val();
|
||||||
|
setTableFilter(tableId, "search", searchString);
|
||||||
|
setTableFilter(tableId, "page", 1); // back to first page, as desired page no. might not exist after filtering
|
||||||
|
|
||||||
|
_searchTimeouts[tableId] = setTimeout(function() {
|
||||||
|
$(tableId).trigger("doDataLoad");
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Button Controls
|
||||||
|
|
||||||
|
function bindRefreshButton(tableId) {
|
||||||
|
const $tableFilters = $(tableId).closest('.js-tableBody').siblings('.js-tableFilters');
|
||||||
|
$tableFilters.on("click", ".table-refresh-btn", function() {
|
||||||
|
$tableFilters.find(".table-del-btn").prop("disabled", true);
|
||||||
|
$(tableId).trigger("doDataLoad");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Select Checkboxes
|
||||||
|
|
||||||
|
function bindTableSelectColumn(tableId) {
|
||||||
|
$(tableId).on("change", "tbody tr .table-select-row", function() {
|
||||||
|
let selected = $(this).prop("checked");
|
||||||
|
let rowIndex = $(this).closest("tr").index();
|
||||||
|
let row = $(tableId).DataTable().row(rowIndex);
|
||||||
|
|
||||||
|
selected === true ? row.select() : row.deselect();
|
||||||
|
determineSelectAllState(tableId);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(tableId).on("change", "thead .table-select-all", function() {
|
||||||
|
let selected = $(this).prop("checked");
|
||||||
|
let table = $(tableId).DataTable();
|
||||||
|
$(tableId).find("tbody tr").each(function(rowIndex) {
|
||||||
|
let row = table.row(rowIndex);
|
||||||
|
selected === true ? row.select() : row.deselect();
|
||||||
|
$(this).find(".table-select-row").prop("checked", selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
determineSelectAllState(tableId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineSelectAllState(tableId) {
|
||||||
|
let table = $(tableId).DataTable();
|
||||||
|
let selectedRowsCount = table.rows(".selected").data().toArray().length;
|
||||||
|
let allRowsCount = table.rows().data().toArray().length;
|
||||||
|
|
||||||
|
let doCheck = selectedRowsCount === allRowsCount;
|
||||||
|
let doIndeterminate = !doCheck && selectedRowsCount > 0;
|
||||||
|
|
||||||
|
$checkbox = $(tableId).find("thead .table-select-all");
|
||||||
|
$checkbox.prop("checked", doCheck);
|
||||||
|
$checkbox.prop("indeterminate", doIndeterminate);
|
||||||
|
|
||||||
|
$(tableId).closest(".js-tableBody").siblings(".js-tableFilters").find(".table-del-btn").prop("disabled", !doCheck && !doIndeterminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region On/Off Controls
|
||||||
|
|
||||||
|
function enableTableControls(tableId) {
|
||||||
|
setTableControlsUsability(tableId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableTableControls(tableId) {
|
||||||
|
setTableControlsUsability(tableId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableControlsUsability(tableId, disabled) {
|
||||||
|
const $table = $(tableId);
|
||||||
|
const $tableBody = $table.closest(".js-tableBody");
|
||||||
|
const $tableFilters = $tableBody.siblings(".js-tableFilters");
|
||||||
|
const $tableControls = $tableBody.siblings(".tableControls");
|
||||||
|
|
||||||
|
$tableBody.find(".disable-while-loading").prop("disabled", disabled);
|
||||||
|
$tableFilters.find(".disable-while-loading").prop("disabled", disabled);
|
||||||
|
$tableControls.find(".disable-while-loading").prop("disabled", disabled);
|
||||||
|
|
||||||
|
// Re-focus search bar if used
|
||||||
|
const $search = $tableFilters.find(".disable-while-loading[type=search]");
|
||||||
|
$search.data("was-focused") ? $search.focus() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Modals
|
||||||
|
|
||||||
|
|
||||||
|
async function openDataModal(modalId, pk, url) {
|
||||||
|
$modal = $(modalId);
|
||||||
|
$modal.data("primary-key", pk);
|
||||||
|
clearValidation($modal);
|
||||||
|
|
||||||
|
if (parseInt(pk) === -1) {
|
||||||
|
$modal.find(".form-create").show();
|
||||||
|
$modal.find(".form-edit").hide();
|
||||||
|
setDefaultModalData($modal);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$modal.find(".form-create").hide();
|
||||||
|
$modal.find(".form-edit").show();
|
||||||
|
await loadModalData($modal, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modal.modal("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultModalData($modal) {
|
||||||
|
$modal.find("[data-field]").each(function() {
|
||||||
|
const type = $(this).attr("type");
|
||||||
|
const defaultVal = $(this).attr("data-default") || "";
|
||||||
|
|
||||||
|
if (type === "checkbox") {
|
||||||
|
$(this).prop("checked", defaultVal === "true");
|
||||||
|
}
|
||||||
|
else if (type === "datetime-local") {
|
||||||
|
$(this).val(getCurrentDateTime());
|
||||||
|
}
|
||||||
|
else if ($(this).is("select") && defaultVal === "firstOption") {
|
||||||
|
$(this).val($(this).find("option:first").val()).change();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$(this).val(defaultVal).change();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearValidation($modal) {
|
||||||
|
$modal.find(".invalid-feedback").remove();
|
||||||
|
$modal.find(".is-invalid").removeClass("is-invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModalData($modal, url) {
|
||||||
|
const data = await ajaxRequest(url, "GET");
|
||||||
|
|
||||||
|
$modal.find("[data-field]").each(function() {
|
||||||
|
const key = $(this).attr("data-field");
|
||||||
|
const value = data[key];
|
||||||
|
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
$(this).prop("checked", value);
|
||||||
|
}
|
||||||
|
else if (isISODateTimeString(value)) {
|
||||||
|
$(this).val(value.split('+')[0].substring(0, 16));
|
||||||
|
}
|
||||||
|
else if ($(this).attr("type") === "color") {
|
||||||
|
$(this).val(`#${value}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$(this).val(value).change();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onModalSubmit($modal, $table, url) {
|
||||||
|
if (!selectedServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearValidation($modal);
|
||||||
|
let data = { server: selectedServer.id };
|
||||||
|
|
||||||
|
$modal.find("[data-field]").each(function() {
|
||||||
|
const type = $(this).attr("type");
|
||||||
|
const key = $(this).attr("data-field");
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value;
|
||||||
|
switch (type) {
|
||||||
|
case "checkbox":
|
||||||
|
value = $(this).prop("checked");
|
||||||
|
break;
|
||||||
|
case "color":
|
||||||
|
value = $(this).val();
|
||||||
|
value = value ? value.replace("#", "") : value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = $(this).val();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
data[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = objectToFormData(data);
|
||||||
|
const id = $modal.data("primary-key");
|
||||||
|
const isNewItem = parseInt(id) !== -1;
|
||||||
|
const method = isNewItem ? "PUT" : "POST";
|
||||||
|
url = isNewItem ? url + `${id}/` : url;
|
||||||
|
|
||||||
|
ajaxRequest(url, method, formData)
|
||||||
|
.then(response => {
|
||||||
|
$table.trigger("doDataLoad");
|
||||||
|
$modal.modal("hide");
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
logError(error);
|
||||||
|
if (typeof error === "object" && "responseJSON" in error) {
|
||||||
|
renderErrorMessages($modal, error.responseJSON);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Modal Error Msgs
|
||||||
|
|
||||||
|
function renderErrorMessages($modal, errorObj) {
|
||||||
|
for (const key in errorObj) {
|
||||||
|
const value = errorObj[key];
|
||||||
|
const $input = $modal.find(`[data-field="${key}"]`);
|
||||||
|
$input.addClass("is-invalid");
|
||||||
|
$input.nextAll(".form-text").last().after(
|
||||||
|
`<div class="invalid-feedback">${value}</div>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Table Col Types
|
||||||
|
|
||||||
|
function renderEditColumn(data) {
|
||||||
|
const name = sanitise(data);
|
||||||
|
return `<span class="act-as-link edit-modal" role="button">${name}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnchorColumn(name, href) {
|
||||||
|
name = sanitise(name);
|
||||||
|
href = sanitise(href);
|
||||||
|
|
||||||
|
return `<a href="${href}" class="act-as-link">${name}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBooleanColumn(data) {
|
||||||
|
const iconClass = data ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger";
|
||||||
|
return `<i class="bi ${iconClass}"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBadgeColumn(data, colour=null) {
|
||||||
|
let badge = $(`<span class="badge text-bg-secondary rounded-1 border border-1">${data}</span>`)
|
||||||
|
if (colour) {
|
||||||
|
badge[0].style.setProperty("border-color", `#${colour}`, "important");
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge.prop("outerHTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArrayBadgesColumn(data) {
|
||||||
|
let badges = $("<div>");
|
||||||
|
|
||||||
|
data.forEach((item, index) => {
|
||||||
|
let badge = $(`<span class="badge text-bg-secondary rounded-1">${item}</span>`);
|
||||||
|
if (index > 0) { badge.addClass("ms-2") }
|
||||||
|
badges.append(badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
return badges.html();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArrayDropdownColumn(data, icon, headerText) {
|
||||||
|
if (!data.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let $dropdown = $(`
|
||||||
|
<div class="dropdown">
|
||||||
|
<button type="button" class="dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="fs-5 bi ${icon}"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<li><h6 class="dropdown-header">${headerText} (${data.length})</h6></li>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
let $dropdownItems = $dropdown.find(".dropdown-menu")
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
let $itemBtn = $(`<li><button type="button" class="dropdown-item">${item}</button></li>`);
|
||||||
|
$dropdownItems.append($itemBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $dropdown.prop("outerHTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHexColourColumn(data, type, row) {
|
||||||
|
if (!row.is_embed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexWithHashtag = `#${data}`.toUpperCase();
|
||||||
|
|
||||||
|
let icon = $("<div>");
|
||||||
|
icon.addClass("col-hex-icon");
|
||||||
|
icon.css("background-color", hexWithHashtag);
|
||||||
|
|
||||||
|
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}">${icon.prop("outerHTML")}</span>`).tooltip()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMutatorColumn(data) {
|
||||||
|
if (!("id" in data)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $(`<span class="badge text-bg-secondary rounded-1">${data.name}</span>`).prop("outerHTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderLinkToStyleColumn = style => {
|
||||||
|
if (!style.is_embed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexWithHashtag = `#${style.colour}`.toUpperCase();
|
||||||
|
|
||||||
|
let icon = $("<div>");
|
||||||
|
icon.addClass("col-hex-icon js-openSubStyle");
|
||||||
|
icon.css("background-color", hexWithHashtag);
|
||||||
|
|
||||||
|
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}" role="button">${icon.prop("outerHTML")}</span>`).tooltip()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPopoverBadgesColumn = (items, iconClass) => {
|
||||||
|
if (!items.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let $span = $("<span>");
|
||||||
|
$span.attr("data-bs-toggle", "popover");
|
||||||
|
$span.attr("data-bs-trigger", "hover focus");
|
||||||
|
$span.attr("data-bs-custom-class", "table-badge-popover")
|
||||||
|
$span.attr("data-bs-html", "true");
|
||||||
|
|
||||||
|
let $placeholderContainer = $("<div>");
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
let $badge = $("<div>");
|
||||||
|
$badge.addClass("badge text-bg-secondary rounded-1 me-2 mb-2 text-wrap mw-100 text-start");
|
||||||
|
$badge.text(item);
|
||||||
|
$placeholderContainer.append($badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
$span.attr("data-bs-content", $placeholderContainer.html());
|
||||||
|
$span.html(`<i class="bi ${iconClass} fs-5"></i>`)
|
||||||
|
return $span.popover()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Get Table Parts
|
||||||
|
|
||||||
|
function getTableFiltersComponent(tableId) {
|
||||||
|
return $(tableId).closest(".js-tableBody").siblings(".js-tableFilters");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTableControlsComponent(tableId) {
|
||||||
|
return $(tableId).closest(".js-tableBody").siblings(".js-tableControls");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedTableRows(tableId) {
|
||||||
|
return $(tableId).DataTable().rows(".selected").data().toArray();
|
||||||
|
}
|
56
apps/home/static/home/js/tabs/content.js
Normal file
56
apps/home/static/home/js/tabs/content.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
const contentTableId = "#contentTable";
|
||||||
|
|
||||||
|
|
||||||
|
// region Init Module
|
||||||
|
|
||||||
|
function initContentModule() {
|
||||||
|
initializeDataTable(
|
||||||
|
contentTableId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Subscription",
|
||||||
|
data: "subscription"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Item ID",
|
||||||
|
data: "item_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Item GUID",
|
||||||
|
data: "item_guid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Title",
|
||||||
|
data: "item_title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "URL",
|
||||||
|
data: "item_url"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Content Hash",
|
||||||
|
data: "item_content_hash"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Data
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
await loadContentData();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(contentTableId).on("doDataLoad", async function() {
|
||||||
|
await loadContentData();
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadContentData() {
|
||||||
|
if (!selectedServer){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableFilter(contentTableId, "subscription__server", selectedServer.id);
|
||||||
|
await loadTableData(contentTableId, "/api/content/", "GET");
|
||||||
|
}
|
204
apps/home/static/home/js/tabs/filters.js
Normal file
204
apps/home/static/home/js/tabs/filters.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
const filterTableId = "#filterTable";
|
||||||
|
const filterModalId = "#filterFormModal";
|
||||||
|
|
||||||
|
|
||||||
|
// region Init Module
|
||||||
|
|
||||||
|
function initFiltersModule() {
|
||||||
|
initializeDataTable(
|
||||||
|
filterTableId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
data: "name",
|
||||||
|
render: renderEditColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Match",
|
||||||
|
data: "match"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Algorithm",
|
||||||
|
data: "matching_algorithm",
|
||||||
|
render: function(data) {
|
||||||
|
switch (data) {
|
||||||
|
case 1: return "Any Word";
|
||||||
|
case 2: return "All Words";
|
||||||
|
case 3: return "Exact Match";
|
||||||
|
case 4: return "Regular Expression";
|
||||||
|
case 5: return "Fuzzy Match";
|
||||||
|
default:
|
||||||
|
console.error(`unknown matching algorithm '${data}'`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Case-Sensitive",
|
||||||
|
data: "is_insensitive",
|
||||||
|
className: "text-center",
|
||||||
|
render: data => renderBooleanColumn(!data)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Type",
|
||||||
|
data: "is_whitelist",
|
||||||
|
render: data => data ? "Only Allow" : "Reject All"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Data
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
await loadFilterData();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(filterTableId).on("doDataLoad", async function() {
|
||||||
|
await loadFilterData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFilterData() {
|
||||||
|
if (!selectedServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableFilter(filterTableId, "server", selectedServer.id);
|
||||||
|
await loadTableData(filterTableId, "/api/filters/", "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table Delete Btns
|
||||||
|
|
||||||
|
$(filterModalId).find(".modal-del-btn").on("click", async function() {
|
||||||
|
$(filterModalId).modal("hide");
|
||||||
|
|
||||||
|
const id = parseInt($(filterModalId).data("primary-key"));
|
||||||
|
const filter = $(filterTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||||
|
const name = sanitise(filter.name);
|
||||||
|
|
||||||
|
const deleteFilter = async () => {
|
||||||
|
await ajaxRequest(`/api/filters/${filter.id}/`, "DELETE");
|
||||||
|
setTimeout(async () => {
|
||||||
|
$(filterTableId).trigger("doDataLoad");
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="filters"]'),
|
||||||
|
`/api/filters/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: "Delete a Content Filter",
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{ content: "This action is irreversible, you will lose this filter forever." }
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: async () => $(filterModalId).modal("show")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getTableFiltersComponent(filterTableId).find(".table-del-btn").on("click", async function() {
|
||||||
|
const rows = getSelectedTableRows(filterTableId);
|
||||||
|
const isMany = rows.length > 1;
|
||||||
|
const names = rows.map(row => row.name);
|
||||||
|
|
||||||
|
const deleteFilters = async () => {
|
||||||
|
rows.forEach(async row => {
|
||||||
|
await ajaxRequest(`/api/filters/${row.id}/`, "DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
$(filterTableId).trigger("doDataLoad");
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="filters"]'),
|
||||||
|
`/api/filters/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: `Delete ${isMany ? "Many Content Filters" : "Content Filter"}`,
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} content filter${isMany ? "s" : ""}?</p>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||||
|
html: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteFilters
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region New/Edit Modal
|
||||||
|
|
||||||
|
$(filterTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".table-new-btn", async function() {
|
||||||
|
await openDataModal(filterModalId, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(filterTableId).on("click", ".edit-modal", async function() {
|
||||||
|
const id = $(filterTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||||
|
await openDataModal(filterModalId, id, `/api/filters/${id}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(filterModalId).on("submit", async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onModalSubmit(
|
||||||
|
$(filterModalId),
|
||||||
|
$(filterTableId),
|
||||||
|
"/api/filters/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Modal Options
|
||||||
|
|
||||||
|
$(document).ready(async function() {
|
||||||
|
await loadMatchingAlgorithms();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMatchingAlgorithms() {
|
||||||
|
const data = await ajaxRequest("/api/filters/", "OPTIONS");
|
||||||
|
data.actions.GET.matching_algorithm.choices.forEach(algorithm => {
|
||||||
|
$(filterModalId).find('[data-field="matching_algorithm"]').append($(
|
||||||
|
"<option>",
|
||||||
|
{
|
||||||
|
text: algorithm.display_name,
|
||||||
|
value: algorithm.value > 0 ? algorithm.value : ""
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
293
apps/home/static/home/js/tabs/styles.js
Normal file
293
apps/home/static/home/js/tabs/styles.js
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
const styleTableId = "#styleTable";
|
||||||
|
const styleModalId = "#styleFormModal";
|
||||||
|
|
||||||
|
|
||||||
|
// region Init Module
|
||||||
|
|
||||||
|
function initMessageStylesModule() {
|
||||||
|
initializeDataTable(
|
||||||
|
styleTableId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
data: "name",
|
||||||
|
className: "col-name",
|
||||||
|
render: (name, type, style) => {
|
||||||
|
const elem = renderEditColumn(name);
|
||||||
|
return style.auto_created ?
|
||||||
|
$(elem).removeClass("edit-modal").addClass("disabled").attr("role", null)[0]
|
||||||
|
: elem;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Embed",
|
||||||
|
data: "is_embed",
|
||||||
|
className: "col-icon",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Colour",
|
||||||
|
data: "colour",
|
||||||
|
className: "col-hex",
|
||||||
|
render: renderHexColourColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Hyperlinked",
|
||||||
|
data: "is_hyperlinked",
|
||||||
|
className: "col-icon-wide",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Authored",
|
||||||
|
data: "show_author",
|
||||||
|
className: "col-icon-wide",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Timestamped",
|
||||||
|
data: "show_timestamp",
|
||||||
|
className: "col-icon-wide",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Images",
|
||||||
|
data: "show_images",
|
||||||
|
className: "col-icon",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fetch Images",
|
||||||
|
data: "fetch_images",
|
||||||
|
className: "col-icon-wide",
|
||||||
|
render: renderBooleanColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Title Mutator",
|
||||||
|
data: "title_mutator_detail",
|
||||||
|
render: (data, type, row) => renderMutatorColumn(row.title_mutator_detail)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Description Mutator",
|
||||||
|
data: "description_mutator_detail",
|
||||||
|
render: (data, type, row) => renderMutatorColumn(row.description_mutator_detail)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Editable",
|
||||||
|
data: "auto_created",
|
||||||
|
className: "text-center",
|
||||||
|
render: function(data) {
|
||||||
|
const icon = renderBooleanColumn(!data);
|
||||||
|
if (!data) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $(`
|
||||||
|
<span data-bs-trigger="hover focus"
|
||||||
|
data-bs-custom-class="text-center"
|
||||||
|
data-bs-toggle="popover"
|
||||||
|
data-bs-content="This style was created internally, and cannot be altered.">
|
||||||
|
${icon}
|
||||||
|
</span>
|
||||||
|
`).popover()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Data
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
await loadMessageStyleData();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("doDataLoad", async function() {
|
||||||
|
await loadMessageStyleData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMessageStyleData() {
|
||||||
|
if (!selectedServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableFilter(styleTableId, "server", selectedServer.id);
|
||||||
|
await loadTableData(styleTableId, "/api/message-styles/", "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Table Delete Btns
|
||||||
|
|
||||||
|
$(styleModalId).find(".modal-del-btn").on("click", async function() {
|
||||||
|
$(styleModalId).modal("hide");
|
||||||
|
|
||||||
|
const id = parseInt($(styleModalId).data("primary-key"));
|
||||||
|
const style = $(styleTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||||
|
const name = sanitise(style.name);
|
||||||
|
|
||||||
|
const deleteStyle = async () => {
|
||||||
|
await ajaxRequest(`/api/message-styles/${style.id}/`, "DELETE");
|
||||||
|
setTimeout(async () => {
|
||||||
|
$(styleTableId).trigger("doDataLoad");
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="message_style"]'),
|
||||||
|
`/api/message-styles/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: "Delete a Message Style",
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{ content: "This action is irreversible, you will lose this filter forever." }
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteStyle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: async () => $(styleModalId).modal("show")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getTableFiltersComponent(styleTableId).find(".table-del-btn").on("click", async function() {
|
||||||
|
const rows = getSelectedTableRows(styleTableId);
|
||||||
|
const isMany = rows.length > 1;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.auto_created) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: "Cannot Delete Style",
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<p><b>${sanitise(row.name)}</b> can't be deleted, as it was created by the system.</p>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{ content: "System-owned styles cannot be modified or deleted." },
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-warning px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = rows.map(row => row.name);
|
||||||
|
|
||||||
|
const deleteStyles = async () => {
|
||||||
|
rows.forEach(async row => {
|
||||||
|
await ajaxRequest(`/api/message-styles/${row.id}/`, "DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
$(styleTableId).trigger("doDataLoad");
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="message_style"]'),
|
||||||
|
`/api/message-styles/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: `Delete ${isMany ? "Many Message Styles" : "Message Style"}`,
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} message style${isMany ? "s" : ""}?</p>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||||
|
html: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteStyles
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region New/Edit Modal
|
||||||
|
|
||||||
|
$(styleTableId).closest(".js-tableBody").siblings(".js-tableFilters").on("click", ".table-new-btn", async function() {
|
||||||
|
await openDataModal(styleModalId, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(styleTableId).on("click", ".edit-modal", async function() {
|
||||||
|
let id = $(styleTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||||
|
await openDataModal(styleModalId, id, `/api/message-styles/${id}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(styleModalId).on("submit", async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
onModalSubmit(
|
||||||
|
$(styleModalId),
|
||||||
|
$(styleTableId),
|
||||||
|
"/api/message-styles/"
|
||||||
|
).then(async () => {
|
||||||
|
// Reload sub data to reflect style changes
|
||||||
|
$(subTableId).trigger("doDataLoad");
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="message_style"]'),
|
||||||
|
`/api/message-styles/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Mutator Options
|
||||||
|
|
||||||
|
$(document).ready(async function() {
|
||||||
|
await loadMutatorOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMutatorOptions() {
|
||||||
|
let $inputs = $(styleModalId).find('[data-field="title_mutator"], [data-field="description_mutator"]');
|
||||||
|
|
||||||
|
$inputs.val("").change();
|
||||||
|
$inputs.prop("disabled", true);
|
||||||
|
|
||||||
|
$inputs.find("option").each(function() {
|
||||||
|
if ($(this).val()) {
|
||||||
|
$(this).remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await ajaxRequest("/api/message-mutators/?page_size=25", "GET");
|
||||||
|
data.results.forEach(mutator => {
|
||||||
|
$inputs.append($(
|
||||||
|
"<option>",
|
||||||
|
{text: mutator.name, value: mutator.id}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
$inputs.prop("disabled", false);
|
||||||
|
}
|
304
apps/home/static/home/js/tabs/subs.js
Normal file
304
apps/home/static/home/js/tabs/subs.js
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
|
||||||
|
const subTableId = "#subTable";
|
||||||
|
const subModalId = "#subFormModal";
|
||||||
|
|
||||||
|
|
||||||
|
// region Init Module
|
||||||
|
|
||||||
|
function initSubscriptionsModule() {
|
||||||
|
initializeDataTable(
|
||||||
|
subTableId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
data: "name",
|
||||||
|
className: "col-name",
|
||||||
|
render: renderEditColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "URL",
|
||||||
|
data: "url",
|
||||||
|
className: "col-url",
|
||||||
|
render: url => renderAnchorColumn(url, url)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Channels",
|
||||||
|
data: "channels_detail",
|
||||||
|
className: "col-icon",
|
||||||
|
render: data => renderPopoverBadgesColumn(data.map(item => `#${item.name}`), "bi-hash")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Filters",
|
||||||
|
data: "filters_detail",
|
||||||
|
className: "col-icon",
|
||||||
|
render: data => renderPopoverBadgesColumn(data.map(item => item.name), "bi-funnel")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rules",
|
||||||
|
data: "unique_rules_detail",
|
||||||
|
className: "col-icon",
|
||||||
|
render: data => renderPopoverBadgesColumn(data.map(item => item.name), "bi-vr")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Style",
|
||||||
|
data: "message_style_detail",
|
||||||
|
className: "col-hex",
|
||||||
|
render: renderLinkToStyleColumn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Created At",
|
||||||
|
data: "created_at",
|
||||||
|
className: "col-date",
|
||||||
|
render: function(data, type) {
|
||||||
|
let dateTime = new Date(data);
|
||||||
|
return $(`
|
||||||
|
<span data-bs-trigger="hover focus"
|
||||||
|
data-bs-html="true"
|
||||||
|
data-bs-custom-class="text-center"
|
||||||
|
data-bs-toggle="popover"
|
||||||
|
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
|
||||||
|
${formatStringDate(dateTime, "%D, %b %Y")}
|
||||||
|
</span>
|
||||||
|
`).popover()[0];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Enabled",
|
||||||
|
data: "active",
|
||||||
|
className: "col-switch text-center form-switch",
|
||||||
|
render: function(data, type) {
|
||||||
|
return `<input type="checkbox" class="sub-toggle-active form-check-input ms-0" ${data ? "checked" : ""} />`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Data
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
await loadSubscriptionData();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(subTableId).on("doDataLoad", async function() {
|
||||||
|
await loadSubscriptionData();
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSubscriptionData() {
|
||||||
|
if (!selectedServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableFilter(subTableId, "server", selectedServer.id);
|
||||||
|
await loadTableData(subTableId, `/api/subscriptions/`, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Table Switches
|
||||||
|
|
||||||
|
$(subTableId).on("change", ".sub-toggle-active", async function() {
|
||||||
|
|
||||||
|
// Temporarily disable all switches to prevent spam.
|
||||||
|
$(subTableId).find(".sub-toggle-active").prop("disabled", true);
|
||||||
|
setTimeout(() => { $(subTableId).find(".sub-toggle-active").prop("disabled", false); }, 800);
|
||||||
|
|
||||||
|
let active = $(this).prop("checked");
|
||||||
|
let id = $(subTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||||
|
let sub = await ajaxRequest(`/api/subscriptions/${id}/`, "GET");
|
||||||
|
sub.active = active;
|
||||||
|
let formData = objectToFormData(sub);
|
||||||
|
await ajaxRequest(`/api/subscriptions/${id}/`, "PUT", formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Table Delete Buttons
|
||||||
|
|
||||||
|
$(subModalId).find(".modal-del-btn").on("click", async function() {
|
||||||
|
$(subModalId).modal("hide");
|
||||||
|
|
||||||
|
const id = parseInt($(subModalId).data("primary-key"));
|
||||||
|
const subscription = $(subTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||||
|
const name = sanitise(subscription.name);
|
||||||
|
|
||||||
|
const deleteSubscription = async () => {
|
||||||
|
await ajaxRequest(`/api/subscriptions/${subscription.id}/`, "DELETE");
|
||||||
|
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: "Delete a Subscriptions",
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{ content: "This action is irreversible, you will lose this subscription forever." }
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteSubscription,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: async () => $(subModalId).modal("show")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
getTableFiltersComponent(subTableId).find(".table-del-btn").on("click", async function() {
|
||||||
|
const rows = getSelectedTableRows(subTableId);
|
||||||
|
const names = rows.map(row => row.name);
|
||||||
|
const isMany = names.length > 1;
|
||||||
|
|
||||||
|
const deleteSubscriptions = () => {
|
||||||
|
rows.forEach(async row => {
|
||||||
|
await ajaxRequest(`/api/subscriptions/${row.id}/`, "DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal({
|
||||||
|
title: `Delete ${isMany ? "Many Subscriptions" : "Subscription"}`,
|
||||||
|
texts: [
|
||||||
|
{
|
||||||
|
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} subscription${isMany ? "s" : ""}?</p>`,
|
||||||
|
html: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||||
|
html: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
className: "btn-danger me-3",
|
||||||
|
iconClass: "bi-trash3",
|
||||||
|
closeModal: true,
|
||||||
|
onClick: deleteSubscriptions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "btn-secondary px-4",
|
||||||
|
iconClass: "bi-arrow-return-right",
|
||||||
|
closeModal: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region New/Edit Modal
|
||||||
|
|
||||||
|
$(subTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".table-new-btn", async function() {
|
||||||
|
await openSubModal(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(subTableId).on("click", ".edit-modal", async function() {
|
||||||
|
const id = $(subTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||||
|
await openSubModal(id);
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openSubModal(id) {
|
||||||
|
await loadChannelOptions();
|
||||||
|
await openDataModal(subModalId, id, id !== -1 ? `/api/subscriptions/${id}/`: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(subModalId).on("submit", async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onModalSubmit(
|
||||||
|
$(subModalId),
|
||||||
|
$(subTableId),
|
||||||
|
"/api/subscriptions/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// region Load Modal Options
|
||||||
|
|
||||||
|
$(document).on("selectedServerChange", async function() {
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="message_style"]'),
|
||||||
|
`/api/message-styles/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="filters"]'),
|
||||||
|
`/api/filters/?server=${selectedServer.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).ready(async function() {
|
||||||
|
await loadSubModalOptions(
|
||||||
|
$(subModalId).find('[data-field="unique_rules"]'),
|
||||||
|
"/api/unique-content-rules/"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSubModalOptions($input, url) {
|
||||||
|
// Disable and clear input
|
||||||
|
$input.val("").change();
|
||||||
|
$input.prop("disabled", true);
|
||||||
|
|
||||||
|
// Delete existing options
|
||||||
|
$input.find("option").each(function() {
|
||||||
|
if ($(this).val()) {
|
||||||
|
$(this).remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load new values
|
||||||
|
const data = await ajaxRequest(url, "GET");
|
||||||
|
data.results.forEach(item => {
|
||||||
|
$input.append($(
|
||||||
|
"<option>",
|
||||||
|
{text: item.name, value: item.id}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-enable input
|
||||||
|
$input.prop("disabled", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel options aren't loaded from an API, like other options.
|
||||||
|
async function loadChannelOptions() {
|
||||||
|
$input = $(subModalId).find('[data-field="channels"]');
|
||||||
|
|
||||||
|
$input.val("").change();
|
||||||
|
$input.prop("disabled", true);
|
||||||
|
|
||||||
|
$input.find("option").each(function() {
|
||||||
|
if ($(this).val()) {
|
||||||
|
$(this).remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await loadedChannels(selectedServer.id);
|
||||||
|
data.forEach(item => {
|
||||||
|
$input.append($(
|
||||||
|
"<option>",
|
||||||
|
{text: `#${item.name}`, value: item.id}
|
||||||
|
));
|
||||||
|
})
|
||||||
|
|
||||||
|
$input.prop("disabled", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// region Open Sub Style
|
||||||
|
|
||||||
|
$(subTableId).on("click", ".js-openSubStyle", async event => {
|
||||||
|
const subTable = $(subTableId).DataTable();
|
||||||
|
const row = subTable.row($(event.currentTarget).closest("tr"));
|
||||||
|
const styleId = row.data().message_style
|
||||||
|
|
||||||
|
// Open styles tab and styles modal
|
||||||
|
$("#stylesTab").click();
|
||||||
|
await openDataModal(styleModalId, styleId, `/api/message-styles/${styleId}/`);
|
||||||
|
});
|
204
apps/home/static/home/scss/_.scss
Normal file
204
apps/home/static/home/scss/_.scss
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
|
||||||
|
.render-array-dropdown-column {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// .col-badge {
|
||||||
|
|
||||||
|
// position: absolute;
|
||||||
|
// top: 50%;
|
||||||
|
// left: 50%;
|
||||||
|
// transform: translate(-50%, -50%);
|
||||||
|
// background-color: var(--bs-danger);
|
||||||
|
// border-radius: 50%;
|
||||||
|
// width: 1.25rem;
|
||||||
|
// height: 1.25rem;
|
||||||
|
// display: flex;
|
||||||
|
// justify-content: center;
|
||||||
|
// align-items: center;
|
||||||
|
// flex-shrink: 0;
|
||||||
|
|
||||||
|
// >* {
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
button[data-bs-toggle="dropdown"] {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// .col-badge {
|
||||||
|
|
||||||
|
// position: absolute;
|
||||||
|
// top: 0;
|
||||||
|
// right: 100%;
|
||||||
|
// transform: translateY(-100%);
|
||||||
|
// background-color: var(--bs-danger);
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
box-shadow: var(--bs-box-shadow);
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
|
||||||
|
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.table {
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr.selected > * {
|
||||||
|
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table.dataTable > tbody > tr.selected a {
|
||||||
|
color: var(--bs-link-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fuck ugly <td> height fix */
|
||||||
|
td {
|
||||||
|
height: 1px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
td > .btn-link { padding-left: 0; }
|
||||||
|
|
||||||
|
@-moz-document url-prefix() {
|
||||||
|
tr { height: 100%; }
|
||||||
|
td { height: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Empty Table */
|
||||||
|
|
||||||
|
.table .dt-empty {
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover > .dt-empty {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Table Search */
|
||||||
|
|
||||||
|
.table-search-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 0;
|
||||||
|
border: none;
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-input {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
appearance: none;
|
||||||
|
background-clip: padding-box;
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 1%;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-input:focus {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Button Controls */
|
||||||
|
|
||||||
|
.table-search-buttons > div {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.table-search-buttons > div {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-buttons > div:first-of-type {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Table Border Colour */
|
||||||
|
|
||||||
|
table.dataTable > thead > tr > th, table.dataTable > thead > tr > td {
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dt-container.dt-empty-footer tbody > tr:last-child > * {
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Cell Data Types */
|
||||||
|
|
||||||
|
.table-cell-hex {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
37
apps/home/static/home/scss/index.scss
Normal file
37
apps/home/static/home/scss/index.scss
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@import "bootstrap.scss";
|
||||||
|
@import "./sidebar.scss";
|
||||||
|
@import "./tables.scss";
|
||||||
|
|
||||||
|
|
||||||
|
/* widths */
|
||||||
|
|
||||||
|
.mw-10rem {
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-switch-width {
|
||||||
|
width: 3.5rem;
|
||||||
|
min-width: 3.5rem;
|
||||||
|
max-width: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Server Tabs */
|
||||||
|
|
||||||
|
#serverTabs .nav-item .nav-link:hover:not(.active) {
|
||||||
|
background-color: var(--bs-secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#serverTabs .nav-item .nav-link:not(.active) {
|
||||||
|
color: var(--bs-text-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act-as-link {
|
||||||
|
|
||||||
|
color: rgba(var(--bs-link-color-rgb), 1);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover { color: var(--bs-link-hover-color-rgb); }
|
||||||
|
&:disabled, &.disabled { color: var(--bs-secondary-color) }
|
||||||
|
|
||||||
|
}
|
344
apps/home/static/home/scss/sidebar.scss
Normal file
344
apps/home/static/home/scss/sidebar.scss
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
.sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background-color: rgba(var(--bs-secondary-rgb), 0.50);
|
||||||
|
z-index: 998;
|
||||||
|
display: none; /* Must start as hidden! */
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-sidebar-btn {
|
||||||
|
z-index: 998;
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
|
||||||
|
@include media-breakpoint-down(lg) { display: block; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-rate-limit { display: none; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 300px;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background-color: var(--bs-secondary-bg-subtle);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
// Hide the sidebar
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the sidebar on smaller screens
|
||||||
|
&.visible {
|
||||||
|
@include media-breakpoint-down(lg) { transform: translateX(0); }
|
||||||
|
@include media-breakpoint-down(sm) { width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider { margin: 1rem; }
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1rem 0 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
.sidebar-header-link {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
|
||||||
|
width: 45px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide on larger screens, show on smaller screens
|
||||||
|
.btn-close {
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@include media-breakpoint-down(lg) { display: block; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
|
||||||
|
padding: 0 1rem ;
|
||||||
|
margin-bottom: auto;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
.sidebar-placeholder {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.sidebar-placeholder-image {
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-placeholder-data {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
>.placeholder { border-radius: $border-radius-sm; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
background-color: inherit;
|
||||||
|
|
||||||
|
// Highlight effect
|
||||||
|
&:not(:disabled):hover,
|
||||||
|
&:not(:disabled):focus,
|
||||||
|
&.active {
|
||||||
|
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
|
||||||
|
&.spot {
|
||||||
|
|
||||||
|
border-radius: 0 $border-radius-sm $border-radius-sm 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'Spot' is an alert indicator, that appears as a coloured dot against a sidebar item
|
||||||
|
&.spot {
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
|
||||||
|
content: "";
|
||||||
|
transition: 0.1s ease;
|
||||||
|
transform: translate(-100%, -50%);
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: 50%;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled):hover::before,
|
||||||
|
&:not(:disabled):focus::before,
|
||||||
|
&.active::before {
|
||||||
|
|
||||||
|
transition: 0.15s ease;
|
||||||
|
width: 0.25rem;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.25rem 0 0 0.25rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot Colours
|
||||||
|
&.spot-primary::before { background-color: var(--bs-primary); }
|
||||||
|
&.spot-secondary::before { background-color: var(--bs-secondary); }
|
||||||
|
&.spot-success::before { background-color: var(--bs-success); }
|
||||||
|
&.spot-danger::before { background-color: var(--bs-danger); }
|
||||||
|
&.spot-warning::before { background-color: var(--bs-warning); }
|
||||||
|
&.spot-info::before { background-color: var(--bs-info); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-image {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Includes the server name and id
|
||||||
|
.sidebar-item-data {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&>span {
|
||||||
|
|
||||||
|
text-align: start;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background-color: inherit;
|
||||||
|
|
||||||
|
&:hover, &:focus, &.active, &:has(>.show) { background-color: var(--bs-body-bg); }
|
||||||
|
|
||||||
|
&.sidebar-mini-btn {
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropdown { padding: 0; }
|
||||||
|
|
||||||
|
>.sidebar-menu-btn {
|
||||||
|
|
||||||
|
text-align: start;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-avatar {
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-menu {
|
||||||
|
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
display: flex;
|
||||||
|
inset: auto auto 0 auto !important;
|
||||||
|
// transform: translateX(-50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
border: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background-color: inherit;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
|
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
|
||||||
|
color: var(--bs-white);
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
box-shadow: var(--bs-box-shadow);
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
|
||||||
|
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
213
apps/home/static/home/scss/tables.scss
Normal file
213
apps/home/static/home/scss/tables.scss
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
|
||||||
|
// Table Search Bar
|
||||||
|
.table-search-group {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
|
||||||
|
.table-search-label {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
padding-right: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-input {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
appearance: none;
|
||||||
|
width: 1%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
color: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
background-clip: padding-box;
|
||||||
|
|
||||||
|
// Disable bootstrap's focus highlights
|
||||||
|
&:focus {
|
||||||
|
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge popover
|
||||||
|
.table-badge-popover .popover-body {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 1rem 0.5rem 0.5rem 1rem;
|
||||||
|
max-width: 200px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table Button Controls
|
||||||
|
|
||||||
|
.table-button-controls > div {
|
||||||
|
|
||||||
|
margin-left: 1rem;
|
||||||
|
|
||||||
|
&:first-of-type { margin-left: 0 !important; }
|
||||||
|
|
||||||
|
@include media-breakpoint-down(md) { margin-left: 0.25rem; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Table
|
||||||
|
.table {
|
||||||
|
|
||||||
|
td, th { vertical-align: middle; }
|
||||||
|
th { text-wrap: nowrap; }
|
||||||
|
|
||||||
|
// Top & bottom border colours
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
tr:first-child > *,
|
||||||
|
tr:last-child > * {
|
||||||
|
border-color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected rows
|
||||||
|
tr.selected {
|
||||||
|
|
||||||
|
> * {
|
||||||
|
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
|
||||||
|
color: var(--bs-link-color) !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
thead { }
|
||||||
|
tbody { }
|
||||||
|
tfoot { }
|
||||||
|
|
||||||
|
// Custom column sizes
|
||||||
|
@mixin col-styles($min-width: null, $max-width: null) {
|
||||||
|
|
||||||
|
$max-width: if($max-width == null, $min-width, $max-width);
|
||||||
|
|
||||||
|
min-width: $min-width;
|
||||||
|
max-width: $max-width;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$col-check: 53px;
|
||||||
|
$col-label: 350px;
|
||||||
|
$col-url: 420px;
|
||||||
|
$col-date: 150px;
|
||||||
|
$col-switch: 100px;
|
||||||
|
$col-icon: 120px;
|
||||||
|
|
||||||
|
.col {
|
||||||
|
|
||||||
|
&-name { @include col-styles($col-label); }
|
||||||
|
&-url { @include col-styles($col-url, $col-url * 2); }
|
||||||
|
&-date { @include col-styles($col-date); }
|
||||||
|
&-checkbox { @include col-styles($col-check); }
|
||||||
|
&-switch { @include col-styles($col-switch); }
|
||||||
|
&-icon { text-align: center; @include col-styles($col-icon); }
|
||||||
|
&-icon-wide { text-align: center; @include col-styles($col-icon, $col-icon + 30px) }
|
||||||
|
&-hex {
|
||||||
|
text-align: center;
|
||||||
|
@include col-styles($col-icon);
|
||||||
|
|
||||||
|
.col-hex-icon {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// .table {
|
||||||
|
|
||||||
|
// // color: var(--bs-body-color);
|
||||||
|
|
||||||
|
// // // Empty table
|
||||||
|
// // .dt-empty {
|
||||||
|
|
||||||
|
// // padding: 2rem 0;
|
||||||
|
// // border-bottom: none;
|
||||||
|
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// // Top & bottom border colour
|
||||||
|
// &.dataTable > thead > tr > th,
|
||||||
|
// &.dataTable > thead > tr > td {
|
||||||
|
|
||||||
|
// border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .dt-container.dt-empty-footer tbody > tr:last-child > * {
|
||||||
|
|
||||||
|
// border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // tr:hover > .dt-empty {
|
||||||
|
|
||||||
|
// // box-shadow: none !important;
|
||||||
|
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// // tbody {
|
||||||
|
|
||||||
|
// // tr.selected > * {
|
||||||
|
|
||||||
|
// // color: var(--bs-body-color);
|
||||||
|
// // box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9);
|
||||||
|
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
183
apps/home/templates/home/index.html
Normal file
183
apps/home/templates/home/index.html
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load compress %}
|
||||||
|
|
||||||
|
{% block title %}{% endblock title %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{% compress css %}
|
||||||
|
<link type="text/x-scss" rel="stylesheet" href="{% static '/home/scss/index.scss' %}">
|
||||||
|
{% endcompress %}
|
||||||
|
<link type="text/css" rel="stylesheet" href="{% static '/home/css/tables.css' %}">
|
||||||
|
<link type="text/css" rel="stylesheet" href="{% static '/css/select2.css' %}">
|
||||||
|
{% endblock stylesheets %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-0 h-100">
|
||||||
|
<div class="d-flex flex-nowrap h-100">
|
||||||
|
|
||||||
|
{% include "home/sidebar.html" %}
|
||||||
|
|
||||||
|
<div class="flex-grow-1 container-fluid bg-body overflow-y-auto" style="min-width: 0;">
|
||||||
|
<div id="noSelectedServer" class="h-100">
|
||||||
|
<div class="d-flex justify-content-center align-items-center flex-column h-100">
|
||||||
|
<img src="{% static '/images/pyrss_logo.webp' %}" alt="PYRSS Logo">
|
||||||
|
<h1 class="fw-bold mb-4 font-atkinson-hyperlegible">PYRSS</h1>
|
||||||
|
<div class="d-flex align-items-center flex-nowrap flex-column">
|
||||||
|
<p class="col-lg-8 text-center">Select a server from the left hand menu to get started. For more help check the <a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-decoration-none" target="_blank">README</a>.</p>
|
||||||
|
<div class="col-lg-8 text-center">
|
||||||
|
<h5>Resources</h5>
|
||||||
|
<div class="hstack gap-3 justify-content-center">
|
||||||
|
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="text-body text-decoration-none" target="_blank"><i class="bi bi-git fs-3"></i></a>
|
||||||
|
<a href="https://en.wikipedia.org/wiki/RSS" class="text-body text-decoration-none" target="_blank"><i class="bi bi-rss-fill fs-3"></i></a>
|
||||||
|
<a href="https://discord.com/developers/docs/intro" class="text-body text-decoration-none" target="_blank"><i class="bi bi-discord fs-3"></i></a>
|
||||||
|
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-body text-decoration-none" target="_blank"><i class="bi bi-question-circle-fill fs-3"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="selectedServerContainer" class="row" style="display: none;">
|
||||||
|
<div id="serverJoinAlert" class="col-12 m-0">
|
||||||
|
<div class="alert alert-warning rounded-1 px-sm-3 mt-2 mt-sm-4 mx-sm-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xxl-10 d-flex align-items-center">
|
||||||
|
<div class="mb-4 mb-xxl-0">
|
||||||
|
<strong>Warning:</strong>
|
||||||
|
<br class="d-xxl-none">
|
||||||
|
The Bot isn't a member of
|
||||||
|
<span class="resolve-to-server-name"></span>,
|
||||||
|
features here will not function properly, please add the bot before proceeding.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xxl-2 text-xxl-end">
|
||||||
|
<a class="btn btn-warning rounded-1 text-nowrap resolve-to-invite-link" target="_blank">Add PYRSS</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 bg-body-tertiary">
|
||||||
|
<div class="row py-3 px-sm-3">
|
||||||
|
<div class="col-sm-4 col-xxl-3 mb-4 mb-sm-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<img class="resolve-to-server-icon rounded-1 me-3 text-center" width="40">
|
||||||
|
<div>
|
||||||
|
<h5 class="resolve-to-server-name mb-0"></h5>
|
||||||
|
<h6 class="resolve-to-server-id small mb-0"></h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-xxl-9">
|
||||||
|
<ul class="nav nav-pills justify-content-sm-end" role="tablist">
|
||||||
|
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||||
|
<button id="subscriptionsTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#subscriptionsTabPane" type="button" aria-controls="subscriptionsTabPane" aria-selected="false">
|
||||||
|
<i class="bi bi-layers"></i>
|
||||||
|
<span class="ms-2 d-none d-lg-inline">Subscriptions</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||||
|
<button id="filtersTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#filtersTabPane" type="button" aria-controls="filtersTabPane" aria-selected="false">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
<span class="ms-2 d-none d-lg-inline">Content Filters</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||||
|
<button id="stylesTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#stylesTabPane" type="button" aria-controls="stylesTabPane" aria-selected="false">
|
||||||
|
<i class="bi bi-border-style"></i>
|
||||||
|
<span class="ms-2 d-none d-lg-inline">Message Styles</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item me-lg-3" role="presentation">
|
||||||
|
<button id="contentTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#contentTabPane" type="button" aria-controls="contentTabPane" aria-selected="false">
|
||||||
|
<i class="bi bi-archive"></i>
|
||||||
|
<span class="ms-2 d-none d-lg-inline">Tracked Content</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item me-0 dropdown">
|
||||||
|
<button type="button" class="nav-link dropdown-toggle rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="js-serverUsersBtn dropdown-item">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
<span class="ms-2">Other Users</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="js-serverHistoryBtn dropdown-item">
|
||||||
|
<i class="bi bi-clock"></i>
|
||||||
|
<span class="ms-2">Edit History</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="js-eraseServerBtn dropdown-item text-danger">
|
||||||
|
<i class="bi-trash3"></i>
|
||||||
|
<span class="ms-2">Delete Data</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div id="serverTabContent" class="tab-content">
|
||||||
|
<div id="subscriptionsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="subscriptionsTab" tabindex="0">
|
||||||
|
{% include "home/tabs/subs.html" %}
|
||||||
|
</div>
|
||||||
|
<div id="filtersTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="filtersTab" tabindex="0">
|
||||||
|
{% include "home/tabs/filters.html" %}
|
||||||
|
</div>
|
||||||
|
<div id="stylesTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="stylesTab" tabindex="0">
|
||||||
|
{% include "home/tabs/styles.html" %}
|
||||||
|
</div>
|
||||||
|
<div id="contentTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="contentTab" tabindex="0">
|
||||||
|
{% include "home/tabs/content.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "home/modals/editSub.html" %}
|
||||||
|
{% include "home/modals/editFilter.html" %}
|
||||||
|
{% include "home/modals/editStyle.html" %}
|
||||||
|
{% include "home/modals/editServer.html" %}
|
||||||
|
{% include "home/modals/modals.html" %}
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block javascript %}
|
||||||
|
<script id="serverItemTemplate" type="text/template">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="sidebar-item">
|
||||||
|
<img src="" alt="" class="sidebar-item-image js-image">
|
||||||
|
<div class="sidebar-item-data">
|
||||||
|
<span class="js-name"></span>
|
||||||
|
<span class="js-id text-body-secondary font-monospace"></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</script>
|
||||||
|
<script id="serverItemIconTemplate" type="text/template">
|
||||||
|
<div class="dot-container m-1" data-bs-toggle="tooltip" data-bs-placement="right">
|
||||||
|
<i class="dot-icon bg-warning "></i>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
<script src="{% static 'js/api.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/index.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/modals.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/servers.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/tables.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/tabs/subs.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/tabs/filters.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/tabs/content.js' %}"></script>
|
||||||
|
<script src="{% static 'home/js/tabs/styles.js' %}"></script>
|
||||||
|
{% endblock javascript %}
|
66
apps/home/templates/home/modals/editFilter.html
Normal file
66
apps/home/templates/home/modals/editFilter.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
|
||||||
|
<div id="filterFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<form id="filterForm" class="mb-0" novalidate>
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title ms-2">
|
||||||
|
<span class="form-create">Add</span>
|
||||||
|
<span class="form-edit">Edit</span>
|
||||||
|
Content Filter
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="filterName" class="form-label">Name</label>
|
||||||
|
<input type="text" name="filterName" id="filterName" class="form-control rounded-1" data-field="name" tabindex="1">
|
||||||
|
<div class="form-text">Human-readable name for this entry.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="filterMatchingAlgorithm" class="form-label">Matching Algorithm</label>
|
||||||
|
<select name="filterMatchingAlgorithm" id="filterMatchingAlgorithm" class="select-2" data-field="matching_algorithm" tabindex="2"></select>
|
||||||
|
<div class="form-text">The algorithm used to match against.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="filterMatch" class="form-label">Match</label>
|
||||||
|
<input type="text" name="filterMatch" id="filterMatch" class="form-control rounded-1" data-field="match" tabindex="3">
|
||||||
|
<div class="form-text">The value to match against.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="filterIsInsensitive" class="form-check-label">Case-Insensitive</label>
|
||||||
|
<input type="checkbox" name="filterIsInsensitive" id="filterIsInsensitive" class="form-check-input" data-field="is_insensitive" tabindex="4">
|
||||||
|
<div class="form-text">Should case-sensitivity be ignored?</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<label for="filterIsWhitelist" class="form-check-label">Whitelist Mode</label>
|
||||||
|
<input type="checkbox" name="filterIsWhitelist" id="filterIsWhitelist" class="form-check-input" data-field="is_whitelist" tabindex="5">
|
||||||
|
<div class="form-text">Overrides the default blacklist behaviour.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-top-0 px-4">
|
||||||
|
<button type="button" class="btn btn-danger rounded-1 me-auto ms-0 modal-del-btn form-edit" tabindex="6">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="7">
|
||||||
|
<i class="bi bi-floppy"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="8">
|
||||||
|
<i class="bi bi-arrow-return-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -19,14 +19,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="form-switch ps-0">
|
<div class="form-check form-switch">
|
||||||
<label for="guildSettingsActive" class="form-check-label mb-2">Server Active?</label>
|
<label for="guildSettingsActive" class="form-check-label">Server Enabled</label>
|
||||||
<br>
|
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input" role="switch">
|
||||||
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input ms-0 mt-0">
|
<div class="form-text">Disabled servers will not process Subscriptions.</div>
|
||||||
<br>
|
|
||||||
<div class="form-text">Is this server active?</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% comment %}
|
||||||
|
<hr class="my-4">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-outline-primary">Reset All Publish Thresholds</button>
|
||||||
|
<div class="form-text">Subscription content predating the threshold won't be processed.</div>
|
||||||
|
</div>
|
||||||
|
{% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer px-4">
|
<div class="modal-footer px-4">
|
||||||
<button type="submit" class="btn btn-primary rounded-1 ms-3 me-0">Save Changes</button>
|
<button type="submit" class="btn btn-primary rounded-1 ms-3 me-0">Save Changes</button>
|
108
apps/home/templates/home/modals/editStyle.html
Normal file
108
apps/home/templates/home/modals/editStyle.html
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<div id="styleFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<form id="styleForm" class="mb-0" novalidate>
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title ms-2">
|
||||||
|
<span class="form-create">Add</span>
|
||||||
|
<span class="form-edit">Edit</span>
|
||||||
|
Message Style
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="styleName" class="form-label">Name</label>
|
||||||
|
<input type="text" name="styleName" id="styleName" class="form-control rounded-1" data-field="name" tabindex="1">
|
||||||
|
<div class="form-text">Human-readable name for this entry.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="colour-input"
|
||||||
|
data-id="styleEmbedColour"
|
||||||
|
data-label="Embed Colour"
|
||||||
|
data-helptext="Colour of the embed (if enabled)."
|
||||||
|
data-defaultcolour="#3498db"
|
||||||
|
data-field="colour">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleIsEmbed" class="form-check-label">Use an Embed</label>
|
||||||
|
<input type="checkbox" name="styleIsEmbed" id="styleIsEmbed" class="form-check-input" data-field="is_embed" tabindex="2">
|
||||||
|
<div class="form-text">Display in an Embed, instead of plain text.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleIsHyperlinked" class="form-check-label">Hyperlink the Title</label>
|
||||||
|
<input type="checkbox" name="styleIsHyperlinked" id="styleIsHyperlinked" class="form-check-input" data-field="is_hyperlinked" tabindex="3">
|
||||||
|
<div class="form-text">Click the title to go to the url.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleShowAuthor" class="form-check-label">Show the Author</label>
|
||||||
|
<input type="checkbox" name="styleShowAuthor" id="styleShowAuthor" class="form-check-input" data-field="show_author" tabindex="4">
|
||||||
|
<div class="form-text">Show the content author if possible.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleShowTimestamp" class="form-check-label">Show the Publish Date</label>
|
||||||
|
<input type="checkbox" name="styleShowTimestamp" id="styleShowTimestamp" class="form-check-input" data-field="show_timestamp" tabindex="5">
|
||||||
|
<div class="form-text">Show when the content was published.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleShowImages" class="form-check-label">Show any Images</label>
|
||||||
|
<input type="checkbox" name="styleShowImages" id="styleShowImages" class="form-check-input" data-field="show_images" tabindex="6">
|
||||||
|
<div class="form-text">Show any found images.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<label for="styleFetchImages" class="form-check-label">Fetch missing Images</label>
|
||||||
|
<input type="checkbox" name="styleFetchImages" id="styleFetchImages" class="form-check-input" data-field="fetch_images" tabindex="7">
|
||||||
|
<div class="form-text">If images aren't found, try to fetch them.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4 mb-lg-0">
|
||||||
|
<label for="styleTitleMutator" class="form-label">Title Mutator</label>
|
||||||
|
<select name="styleTitleMutator" id="styleTitleMutator" class="select-2" data-field="title_mutator" tabindex="8">
|
||||||
|
<option value="">-----</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Modify the title in fun ways.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="">
|
||||||
|
<label for="styleDescriptionMutator" class="form-label">Description Mutator</label>
|
||||||
|
<select name="styleDescriptionMutator" id="styleDescriptionMutator" class="select-2" data-field="description_mutator" tabindex="9">
|
||||||
|
<option value="">-----</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Modify the description in fun ways.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer px-4 border-top-0">
|
||||||
|
<button type="button" class="btn btn-danger rounded-1 me-auto ms-0 modal-del-btn form-edit" tabindex="10">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="11">
|
||||||
|
<i class="bi bi-floppy"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="12">
|
||||||
|
<i class="bi bi-arrow-return-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
119
apps/home/templates/home/modals/editSub.html
Normal file
119
apps/home/templates/home/modals/editSub.html
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<div id="subFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<form id="subForm" class="mb-0 needs-validation" novalidate>
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title ms-2">
|
||||||
|
<span class="form-create">Add</span>
|
||||||
|
<span class="form-edit">Edit</span>
|
||||||
|
Subscription
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<input type="hidden" data-role="is-id">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subName" class="form-label">Name</label>
|
||||||
|
<input type="text" id="subName" name="subName" class="form-control rounded-1" required data-field="name" tabindex="1">
|
||||||
|
<div class="form-text">Human-readable name for this entry.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subUrl" class="form-label">URL</label>
|
||||||
|
<input type="url" id="subUrl" name="subUrl" class="form-control rounded-1" required data-field="url" tabindex="2">
|
||||||
|
<div class="form-text">Source of <a href="https://en.wikipedia.org/wiki/RSS" class="text-decoration-none" target="_blank">RSS</a> content.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subMessageStyle" class="form-label">Message Style</label>
|
||||||
|
<select name="subMessageStyle" id="subMessageStyle" class="select-2" data-field="message_style" data-default="firstOption" tabindex="3">
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Appearance of delivered content.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subPubThreshold" class="form-label">Publish Threshold</label>
|
||||||
|
<input type="datetime-local" name="subPubThreshold" id="subPubThreshold" class="form-control rounded-1" required data-field="publish_threshold" tabindex="4">
|
||||||
|
<div class="form-text">Ignore content older than this date.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subChannels" class="form-label">Channels</label>
|
||||||
|
<select name="subChannels" id="subChannels" class="select-2" multiple data-field="channels" tabindex="5"></select>
|
||||||
|
<div class="form-text">Send content to these channels.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subFilters" class="form-label">Filters</label>
|
||||||
|
<select name="subFilters" id="subFilters" class="select-2" multiple data-field="filters" tabindex="6"></select>
|
||||||
|
<div class="form-text">Filter out unwanted content.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 pe-lg-4">
|
||||||
|
<div class="mb-4 mb-lg-0">
|
||||||
|
<label for="subUniqueRules" class="form-label">Uniqueness Rules</label>
|
||||||
|
<select name="subUniqueRules" id="subUniqueRules" class="select-2" multiple required data-field="unique_rules" tabindex="7"></select>
|
||||||
|
<div class="form-text">Rules on telling content apart.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 ps-lg-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<label for="subActive" class="form-check-label">Enabled</label>
|
||||||
|
<input type="checkbox" id="subActive" name="subActive" class="form-check-input" data-field="active" tabindex="8" data-default="true">
|
||||||
|
<div class="form-text">Disabled Subscriptions will be ignored when processing content.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer px-4 border-top-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary rounded-1 me-auto ms-0 form-create" tabindex="10">
|
||||||
|
<i class="bi bi-dice-5"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger rounded-1 me-3 ms-0 modal-del-btn form-edit" tabindex="11">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 me-auto ms-0 px-4 form-edit" data-bs-toggle="modal" data-bs-target="#subContentModal" tabindex="12">
|
||||||
|
<i class="bi bi-archive"></i>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="13">
|
||||||
|
<i class="bi bi-floppy"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="14">
|
||||||
|
<i class="bi bi-arrow-return-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="subContentModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title ms-2">
|
||||||
|
<span>Subscription Content</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-0">
|
||||||
|
Here is the content produced by this subscription.
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-top-0">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 me-0 px-4" data-bs-toggle="modal" data-bs-target="#subFormModal">
|
||||||
|
<i class="bi bi-arrow-return-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
32
apps/home/templates/home/modals/modals.html
Normal file
32
apps/home/templates/home/modals/modals.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<div id="confirmModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title mx-2"></h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-top-0 px-4">
|
||||||
|
<button type="button" class="btn rounded-1 modal-confirm-btn" tabindex="1">
|
||||||
|
<i class="bi"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1 ms-3 ms-0 px-4 modal-dismiss-btn" tabindex="2">
|
||||||
|
<i class="bi bi-arrow-return-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="customModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content rounded-1">
|
||||||
|
<div class="modal-header border-bottom-0">
|
||||||
|
<h5 class="modal-title mx-2"></h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4"></div>
|
||||||
|
<div class="modal-footer border-top-0 px-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
112
apps/home/templates/home/sidebar.html
Normal file
112
apps/home/templates/home/sidebar.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% load static %}
|
||||||
|
<div class="sidebar-backdrop"></div>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<a href="/" class="sidebar-header-link me-auto">
|
||||||
|
<img src="{% static '/images/pyrss_logo.webp' %}" alt="pyrss logo" class="sidebar-logo">
|
||||||
|
<span class="sidebar-title font-atkinson-hyperlegible">PYRSS</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn-close"></button>
|
||||||
|
</div>
|
||||||
|
<hr class="sidebar-divider">
|
||||||
|
<ul id="serverList" class="sidebar-content overflow-y-auto">
|
||||||
|
<li class="server-rate-limit">
|
||||||
|
<p class="text-danger">
|
||||||
|
<span>Failed to fetch results - you are being rate limited by Discord.</span>
|
||||||
|
<i class="bi bi-question-circle-fill" data-bs-toggle="tooltip" data-bs-title="Discord rate-limits when requests are made in rapid succession."></i>
|
||||||
|
</p>
|
||||||
|
<button type="button" class="sidebar-btn sidebar-retry-btn w-100 rounded-1">
|
||||||
|
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||||
|
<span>Retry</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for i in "01234567890"|make_list %}
|
||||||
|
<li class="sidebar-loading">
|
||||||
|
<div class="sidebar-placeholder placeholder-wave">
|
||||||
|
<div class="sidebar-placeholder-image placeholder"></div>
|
||||||
|
<div class="sidebar-placeholder-data">
|
||||||
|
<span class="placeholder mb-3 w-75"></span>
|
||||||
|
<span class="placeholder {% if forloop.counter0|divisibleby:2 %}w-100{% else %}w-50{% endif %}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
<hr class="sidebar-divider">
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-btn text-start flex-grow-1 dropdown p-0">
|
||||||
|
<button type="button" class="sidebar-menu-btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<img src="{{ request.user.avatar_url }}" alt="user icon" class="sidebar-avatar">
|
||||||
|
<strong class="text-truncate">{{ request.user.global_name }}</strong>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="dropdown-item" target="_blank">
|
||||||
|
<i class="bi bi-git me-2"></i>
|
||||||
|
<span>Source Code</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/wiki" class="dropdown-item" target="_blank">
|
||||||
|
<i class="bi bi-question-lg me-2"></i>
|
||||||
|
<span>Help</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
{% if request.user.is_superuser %}
|
||||||
|
<li>
|
||||||
|
<a href="/admin/" class="dropdown-item" target="_blank">
|
||||||
|
<i class="bi bi-person-check-fill me-2"></i>
|
||||||
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<form action="/logout/" method="post" class="m-0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-btn sidebar-mini-btn dropdown">
|
||||||
|
<button type="button" class="js-themeMenuBtn sidebar-menu-btn px-3" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-sun"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="theme-menu dropdown-menu dropdown-menu-center">
|
||||||
|
<li>
|
||||||
|
<input type="radio" name="themeToggle" id="themeToggleLight" class="btn-check" value="light" autocomplete="off">
|
||||||
|
<label for="themeToggleLight" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Light Theme">
|
||||||
|
<i class="bi bi-sun"></i>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<input type="radio" name="themeToggle" id="themeToggleDark" class="btn-check" value="dark" autocomplete="off">
|
||||||
|
<label for="themeToggleDark" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Dark Theme">
|
||||||
|
<i class="bi bi-moon-stars"></i>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<input type="radio" name="themeToggle" id="themeToggleAuto" class="btn-check" value="auto" autocomplete="off">
|
||||||
|
<label for="themeToggleAuto" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Browser Preferred Theme">
|
||||||
|
<i class="bi bi-circle-half"></i>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="js-pinSidebar sidebar-btn sidebar-mini-btn d-flex d-lg-none">
|
||||||
|
<i class="bi bi-pin-angle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="reveal-sidebar-btn rounded-1 btn btn-lg btn-primary shadow">
|
||||||
|
<i class="bi bi-list"></i>
|
||||||
|
</button>
|
75
apps/home/templates/home/tabs/content.html
Normal file
75
apps/home/templates/home/tabs/content.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||||
|
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
|
||||||
|
<div class="table-search-group mb-lg-0 mb-3">
|
||||||
|
<label for="searchForContent" class="table-search-label">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</label>
|
||||||
|
<input type="search" id="searchForContent" class="table-search-input disable-while-loading" placeholder="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-sort-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-sort-alpha-up"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Sort By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-filter-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Filter By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||||
|
<table id="contentTable" class="table table-hover"></table>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableControls row px-sm-3 mb-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-prev rounded-start-1">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-next rounded-end-1">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||||
|
<label for="contentTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||||
|
<select name="contentTablePageSizer" id="contentTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
|
||||||
|
<option value="1">1 </option>
|
||||||
|
<option value="10" selected>10 </option>
|
||||||
|
<option value="15">15 </option>
|
||||||
|
<option value="20">20 </option>
|
||||||
|
<option value="25">25 </option>
|
||||||
|
</select>
|
||||||
|
<span class="ms-2">of </span>
|
||||||
|
<span class="pageinfo-total text-nowrap">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
80
apps/home/templates/home/tabs/filters.html
Normal file
80
apps/home/templates/home/tabs/filters.html
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||||
|
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
|
||||||
|
<div class="table-search-group mb-lg-0 mb-3">
|
||||||
|
<label for="searchForFilter" class="table-search-label">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</label>
|
||||||
|
<input type="search" id="searchForFilter" class="table-search-input" placeholder="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-search-buttons">
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-new-btn btn btn-primary rounded-1">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-sort-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-sort-alpha-up"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Sort By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-filter-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Filter By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||||
|
<table id="filterTable" class="table table-hover"></table>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableControls row px-sm-3 mb-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-prev rounded-start-1">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-next rounded-end-1">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||||
|
<label for="filterTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||||
|
<select name="filterTablePageSizer" id="filterTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
|
||||||
|
<option value="1">1 </option>
|
||||||
|
<option value="10" selected>10 </option>
|
||||||
|
<option value="15">15 </option>
|
||||||
|
<option value="20">20 </option>
|
||||||
|
<option value="25">25 </option>
|
||||||
|
</select>
|
||||||
|
<span class="ms-2">of </span>
|
||||||
|
<span class="pageinfo-total text-nowrap">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
79
apps/home/templates/home/tabs/styles.html
Normal file
79
apps/home/templates/home/tabs/styles.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||||
|
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
|
||||||
|
<div class="table-search-group mb-lg-0 mb-3">
|
||||||
|
<label for="searchForMessageStyle" class="table-search-label">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</label>
|
||||||
|
<input type="search" id="searchForMessageStyle" class="table-search-input" placeholder="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-new-btn btn btn-primary rounded-1">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-sort-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-sort-alpha-up"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Sort By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-filter-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Filter By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||||
|
<table id="styleTable" class="table table-hover"></table>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableControls row px-sm-3 mb-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<nav class="table-pagination mb-4 mb-lg-0">
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-prev rounded-start-1">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-next rounded-end-1">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||||
|
<label for="styleTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||||
|
<select name="styleTablePageSizer" id="styleTablePageSizer" class="select-2 table-page-sizer">
|
||||||
|
<option value="10" selected>10 </option>
|
||||||
|
<option value="15">15 </option>
|
||||||
|
<option value="20">20 </option>
|
||||||
|
<option value="25">25 </option>
|
||||||
|
</select>
|
||||||
|
<span class="ms-2">of </span>
|
||||||
|
<span class="pageinfo-total text-nowrap">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
80
apps/home/templates/home/tabs/subs.html
Normal file
80
apps/home/templates/home/tabs/subs.html
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||||
|
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
|
||||||
|
<div class="table-search-group mb-lg-0 mb-3">
|
||||||
|
<label for="searchForSubscription" class="table-search-label">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</label>
|
||||||
|
<input type="search" id="searchForSubscription" class="table-search-input" placeholder="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-new-btn btn btn-primary rounded-1">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-sort-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-sort-alpha-up"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Sort By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<div class="dropdown table-filter-dropdown">
|
||||||
|
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">Filter By</h6></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-inline-block">
|
||||||
|
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||||
|
<table id="subTable" class="table table-hover"></table>
|
||||||
|
</div>
|
||||||
|
<div class="js-tableControls row px-sm-3 mb-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-prev rounded-start-1">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<button type="button" class="page-link page-next rounded-end-1">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||||
|
<label for="subTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||||
|
<select name="subTablePageSizer" id="subTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
|
||||||
|
<option value="1">1 </option>
|
||||||
|
<option value="10" selected>10 </option>
|
||||||
|
<option value="15">15 </option>
|
||||||
|
<option value="20">20 </option>
|
||||||
|
<option value="25">25 </option>
|
||||||
|
</select>
|
||||||
|
<span class="ms-2">of </span>
|
||||||
|
<span class="pageinfo-total text-nowrap">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -3,8 +3,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from .views import IndexView
|
from .views import IndexView, GuildsView, ChannelsView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", login_required(IndexView.as_view()), name="index"),
|
path("", login_required(IndexView.as_view()), name="index"),
|
||||||
|
path("generate-servers/", GuildsView.as_view(), name="generate-servers"),
|
||||||
|
path("generate-channels/", ChannelsView.as_view(), name="generate-channels"),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,19 @@
|
|||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
from django.views.generic import TemplateView
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.conf import settings
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.http import JsonResponse, HttpResponseNotFound, HttpResponseNotAllowed
|
||||||
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
|
from apps.home.models import Server, DiscordChannel
|
||||||
|
from apps.api.serializers import ServerSerializer
|
||||||
|
from apps.authentication.models import DiscordUser, ServerMember
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
@ -9,3 +22,212 @@ class IndexView(TemplateView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
template_name = "home/index.html"
|
template_name = "home/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
@sync_to_async
|
||||||
|
def get_user_access_token(user: DiscordUser) -> str:
|
||||||
|
return user.access_token
|
||||||
|
|
||||||
|
@sync_to_async
|
||||||
|
def is_user_authenticated(user: DiscordUser) -> bool:
|
||||||
|
return user.is_authenticated
|
||||||
|
|
||||||
|
|
||||||
|
class GuildsView(View):
|
||||||
|
"""
|
||||||
|
Fetches the related guilds to the currently authenticated user from Discord.
|
||||||
|
Will return a filtered list of the results, excluding servers where the
|
||||||
|
user isn't an administrator or owner.
|
||||||
|
|
||||||
|
Valid servers will also be stored in the database for future reference,
|
||||||
|
along-side the user-server relationship as a member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get(self, request, *args, **kwargs):
|
||||||
|
if not await is_user_authenticated(request.user):
|
||||||
|
return redirect("/oauth2/login")
|
||||||
|
|
||||||
|
access_token = await get_user_access_token(request.user)
|
||||||
|
guilds_data, status = await self._get_guilds_data(access_token)
|
||||||
|
|
||||||
|
# Send back the error data if status is bad
|
||||||
|
if status != 200:
|
||||||
|
return JsonResponse(guilds_data, status=status, safe=False)
|
||||||
|
|
||||||
|
cleaned_guilds_data = await self._clean_guilds_data(request.user, guilds_data)
|
||||||
|
return JsonResponse(cleaned_guilds_data, safe=False)
|
||||||
|
|
||||||
|
async def _get_guilds_data(self, access_token: str) -> tuple[list[dict], int]:
|
||||||
|
"""
|
||||||
|
Returns the raw guild data and a response status code
|
||||||
|
from the Discord API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
return response.json(), response.status_code
|
||||||
|
|
||||||
|
async def _clean_guilds_data(self, user: DiscordUser, guilds_data: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Returns a filtered copy of the given `guilds_data`, without the guilds
|
||||||
|
where the given `user` is not an administrator or owner of.
|
||||||
|
|
||||||
|
Also, for each guild, creates/updates an object to represent it, and
|
||||||
|
another to represent the user/guild relationship as a member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cleaned_guilds_data = []
|
||||||
|
|
||||||
|
for item in guilds_data:
|
||||||
|
cleaned_data = await self._setup_server(user, item)
|
||||||
|
if cleaned_data:
|
||||||
|
cleaned_guilds_data.append(cleaned_data)
|
||||||
|
|
||||||
|
return cleaned_guilds_data
|
||||||
|
|
||||||
|
async def _setup_server(self, user: DiscordUser, item: dict) -> dict[str] | None:
|
||||||
|
"""
|
||||||
|
Create or update a server and user's membership to said server.
|
||||||
|
Returns the cleaned server data, or NoneType if the user isn't
|
||||||
|
an admin or owner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Collect some commonly used server data
|
||||||
|
server_id = item["id"]
|
||||||
|
is_owner = item["owner"]
|
||||||
|
permissions = item["permissions"]
|
||||||
|
admin_perm = 1 << 3
|
||||||
|
|
||||||
|
# Skip servers where the user isn't an administrator or owner.
|
||||||
|
# If an older member object exists, delete that too.
|
||||||
|
if not ((int(permissions) & admin_perm) == admin_perm or is_owner):
|
||||||
|
await self._try_delete_member(user, server_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create or update an existing server matching the given ID
|
||||||
|
server, created = await Server.objects.aupdate_or_create(
|
||||||
|
id=server_id,
|
||||||
|
defaults={
|
||||||
|
"name": item["name"],
|
||||||
|
"icon_hash": item["icon"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create or update a member object linking the user to the server
|
||||||
|
await ServerMember.objects.aupdate_or_create(
|
||||||
|
user=user,
|
||||||
|
server=server,
|
||||||
|
defaults={
|
||||||
|
"permissions": permissions,
|
||||||
|
"is_owner": is_owner
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ServerSerializer(server).data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _try_delete_member(user: DiscordUser, server_id: int):
|
||||||
|
"""
|
||||||
|
Attempt to delete any existing server member linked to the given
|
||||||
|
server id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
member = await ServerMember.objects.aget(user=user, server_id=server_id)
|
||||||
|
await member.adelete()
|
||||||
|
except ServerMember.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsView(View):
|
||||||
|
async def get(self, request, *args, **kwargs):
|
||||||
|
if not await is_user_authenticated(request.user):
|
||||||
|
return redirect("/oauth2/login")
|
||||||
|
|
||||||
|
guild_id = request.GET.get("guild")
|
||||||
|
try: server = await Server.objects.aget(pk=guild_id)
|
||||||
|
except Server.DoesNotExist: return HttpResponseNotFound("Server not found.")
|
||||||
|
|
||||||
|
if not await ServerMember.objects.filter(server=server, user=request.user).aexists():
|
||||||
|
return HttpResponseNotAllowed("You aren't a member of this server.")
|
||||||
|
|
||||||
|
channels_data, status = await self._get_channel_data(guild_id)
|
||||||
|
|
||||||
|
# Not authorized means the bot isn't operational, we need to save that
|
||||||
|
if status == 403:
|
||||||
|
server.is_bot_operational = False
|
||||||
|
await server.asave()
|
||||||
|
|
||||||
|
# Send back the error data if status is bad
|
||||||
|
if status != 200:
|
||||||
|
return JsonResponse(channels_data, status=status, safe=False)
|
||||||
|
|
||||||
|
# Because the status is 200, we know the Bot is functional, set that param
|
||||||
|
server.is_bot_operational = True
|
||||||
|
await server.asave()
|
||||||
|
|
||||||
|
cleaned_channels_data = await self._clean_channels_data(server, channels_data)
|
||||||
|
await self._cleanup_dead_channels(server, cleaned_channels_data)
|
||||||
|
|
||||||
|
return JsonResponse(cleaned_channels_data, safe=False)
|
||||||
|
|
||||||
|
async def _get_channel_data(self, guild_id: int) -> tuple[list[dict], int]:
|
||||||
|
"""
|
||||||
|
Returns the raw channel data and a response status code
|
||||||
|
from the Discord API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
url=f"{settings.DISCORD_API_URL}/guilds/{guild_id}/channels",
|
||||||
|
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
||||||
|
)
|
||||||
|
return response.json(), response.status_code
|
||||||
|
|
||||||
|
async def _clean_channels_data(self, server: Server, channels_data) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Returns a sorted & cleaned list of channel data, also performs a
|
||||||
|
database setup for each channel to be stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cleaned_channels_data = []
|
||||||
|
|
||||||
|
for item in channels_data:
|
||||||
|
cleaned_data = await self._setup_channel(server, item)
|
||||||
|
if cleaned_data:
|
||||||
|
cleaned_channels_data.append(cleaned_data)
|
||||||
|
|
||||||
|
cleaned_channels_data.sort(key=lambda ch: ch.get("position"))
|
||||||
|
return cleaned_channels_data
|
||||||
|
|
||||||
|
async def _setup_channel(self, server: Server, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create or update an instance of DiscordChannel representing the given
|
||||||
|
`data` dictionary, returns the data or `NoneType` if `data['type'] != 0`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Type 0 = TextChannel, the only one we want
|
||||||
|
if data.get("type") != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
await DiscordChannel.objects.aupdate_or_create(
|
||||||
|
id=data["id"],
|
||||||
|
server=server,
|
||||||
|
defaults={
|
||||||
|
"name": data["name"],
|
||||||
|
"is_nsfw": data["nsfw"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def _cleanup_dead_channels(self, server: Server, channel_data: list[dict]):
|
||||||
|
"""
|
||||||
|
Deletes any unused instances of `DiscordChannel` against the given server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_ids = [item["id"] for item in channel_data]
|
||||||
|
count, _ = await DiscordChannel.objects.filter(server=server).exclude(id__in=channel_ids).adelete()
|
||||||
|
log.info("Deleted %s dead DiscordChannel object(s)", count)
|
||||||
|
@ -1,85 +0,0 @@
|
|||||||
@keyframes bump {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(5px); /* Adjust the height of the bump */
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bump {
|
|
||||||
animation: bump .2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-item.active img {
|
|
||||||
border-radius: .75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-item-selector:hover img, .server-item-selector:focus img, .server-item-selector:focus-visible img {
|
|
||||||
border-radius: .75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-item-selector:active, .server-item-selector:focus, .server-item-selector:focus-visible {
|
|
||||||
animation: bump .2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-item-selector img {
|
|
||||||
transition: border-radius .15s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* widths */
|
|
||||||
|
|
||||||
.mw-10rem {
|
|
||||||
max-width: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-switch-width {
|
|
||||||
width: 3.5rem;
|
|
||||||
min-width: 3.5rem;
|
|
||||||
max-width: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tables */
|
|
||||||
|
|
||||||
.table {
|
|
||||||
color: var(--bs-body-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr.selected > * {
|
|
||||||
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
|
|
||||||
color: var(--bs-body-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table.dataTable > tbody > tr.selected a {
|
|
||||||
color: var(--bs-link-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fuck ugly <td> height fix */
|
|
||||||
td {
|
|
||||||
height: 1px;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
td > .btn-link {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@-moz-document url-prefix() {
|
|
||||||
tr {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#serverTabs .nav-link {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#serverTabs .nav-link:not(.active) {
|
|
||||||
color: var(--bs-text-body);
|
|
||||||
}
|
|
@ -1,174 +0,0 @@
|
|||||||
|
|
||||||
async function ajaxRequest(url, method, data) {
|
|
||||||
const options = {
|
|
||||||
url: url,
|
|
||||||
method: method,
|
|
||||||
beforeSend: function(xhr) {
|
|
||||||
xhr.setRequestHeader("X-CSRFToken", CSRF_MiddlewareToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
options.data = data;
|
|
||||||
options.processData = false;
|
|
||||||
options.contentType = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $.ajax(options);
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeQuerystring(filters, sort) {
|
|
||||||
let querystring = "?";
|
|
||||||
for (key in filters) {
|
|
||||||
querystring += `${key}=${filters[key]}&`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sort ? querystring += `ordering=${sort}` : querystring;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Saved Guilds
|
|
||||||
|
|
||||||
async function getSavedGuilds() {
|
|
||||||
return await ajaxRequest("/api/saved-guilds/", "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSavedGuild(id) {
|
|
||||||
return await ajaxRequest(`/api/saved-guilds/${id}/`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newSavedGuild(formData) {
|
|
||||||
return await ajaxRequest("/api/saved-guilds/", "POST", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSavedGuild(id) {
|
|
||||||
return await ajaxRequest(`/api/saved-guilds/${id}/`, "DELETE");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading Guilds
|
|
||||||
|
|
||||||
async function loadGuilds() {
|
|
||||||
return await ajaxRequest("/guilds/", "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading Channels
|
|
||||||
async function loadChannels(guildId) {
|
|
||||||
return await ajaxRequest(`/channels?guild=${guildId}`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscriptions
|
|
||||||
|
|
||||||
async function getSubscriptions(filters, sort) {
|
|
||||||
let querystring = makeQuerystring(filters, sort);
|
|
||||||
return await ajaxRequest(`/api/subscription/${querystring}`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSubscription(id) {
|
|
||||||
return await ajaxRequest(`/api/subscription/${id}/`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newSubscription(formData) {
|
|
||||||
return await ajaxRequest("/api/subscription/", "POST", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSubscription(id) {
|
|
||||||
return await ajaxRequest(`/api/subscription/${id}/`, "DELETE");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editSubscription(id, formData) {
|
|
||||||
return await ajaxRequest(`/api/subscription/${id}/`, "PUT", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSubscriptionOptions() {
|
|
||||||
return await ajaxRequest("/api/subscription/", "OPTIONS")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// SubChannels
|
|
||||||
|
|
||||||
async function getSubChannels(subscriptionId) {
|
|
||||||
return await ajaxRequest(`/api/subchannel/?subscription=${subscriptionId}`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSubChannel(id) {
|
|
||||||
return await ajaxRequest(`/api/subchannel/${id}/`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newSubChannel(formData) {
|
|
||||||
return await ajaxRequest("/api/subchannel/", "POST", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSubChannel(id) {
|
|
||||||
return await ajaxRequest(`/api/subchannel/${id}/`, "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSubChannels(subscriptionId) {
|
|
||||||
return await ajaxRequest(`/api/subscription/${subscriptionId}/subchannels/`, "DELETE");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
|
|
||||||
async function getFilters(filters, sort) {
|
|
||||||
let querystring = makeQuerystring(filters, sort);
|
|
||||||
return await ajaxRequest(`/api/filter/${querystring}`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFilter(id) {
|
|
||||||
return await ajaxRequest(`/api/filter/${id}/`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newFilter(formData) {
|
|
||||||
return await ajaxRequest("/api/filter/", "POST", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFilter(id) {
|
|
||||||
return await ajaxRequest(`/api/filter/${id}/`, "DELETE");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editFilter(id, formData) {
|
|
||||||
return await ajaxRequest(`/api/filter/${id}/`, "PUT", formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFilterOptions() {
|
|
||||||
return await ajaxRequest("/api/filter/", "OPTIONS")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Tracked Content
|
|
||||||
|
|
||||||
async function getTrackedContent(filters, sort) {
|
|
||||||
let querystring = makeQuerystring(filters, sort);
|
|
||||||
url = `/api/tracked-content/${querystring}`;
|
|
||||||
return await ajaxRequest(url, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTrackedContent(guid) {
|
|
||||||
const encodedGuid = encodeURIComponent(guid);
|
|
||||||
return await ajaxRequest(`/api/tracked-content/${encodedGuid}/`, "DELETE");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTrackedContentOptions() {
|
|
||||||
return await ajaxRequest("/api/tracked-content/", "OPTIONS")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutators
|
|
||||||
|
|
||||||
async function getMutators() {
|
|
||||||
return await ajaxRequest("/api/article-mutator/?page_size=25", "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// guild settings
|
|
||||||
|
|
||||||
async function getGuildSettings(guildId) {
|
|
||||||
return await ajaxRequest(`/api/guild-settings/?guild_id=${guildId}`, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function editGuildSettings(id, formData) {
|
|
||||||
return await ajaxRequest(`/api/guild-settings/${id}/`, "PUT", formData);
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
$("#themeToggle").on("click", function() {
|
|
||||||
const currentTheme = $("body").attr("data-bs-theme");
|
|
||||||
const newTheme = currentTheme === "light" ? "dark" : "light";
|
|
||||||
updateTheme(newTheme);
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateTheme(theme) {
|
|
||||||
$("body").attr("data-bs-theme", theme);
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentDateTime() {
|
|
||||||
var now = new Date();
|
|
||||||
var year = now.getFullYear();
|
|
||||||
var month = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
var day = String(now.getDate()).padStart(2, '0');
|
|
||||||
var hours = String(now.getHours()).padStart(2, '0');
|
|
||||||
var minutes = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitise a given string to remove HTML, making it DOM safe.
|
|
||||||
function sanitise(string) {
|
|
||||||
if (typeof string !== "string") {
|
|
||||||
return string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": ''',
|
|
||||||
"/": '/',
|
|
||||||
};
|
|
||||||
const reg = /[&<>"'/]/ig;
|
|
||||||
return string.replace(reg, (match) => map[match]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Activate all tooltips
|
|
||||||
$('[data-bs-toggle="tooltip"]').tooltip();
|
|
||||||
|
|
||||||
// Activate select2s
|
|
||||||
$(".select-2").each(function() {
|
|
||||||
var dropdownParent = $(this).attr("data-dropdownparent");
|
|
||||||
$(this).select2({
|
|
||||||
theme: "bootstrap",
|
|
||||||
minimumResultsForSearch: 10,
|
|
||||||
dropdownParent: dropdownParent
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate datepickers
|
|
||||||
// $(".input-group.date").datepicker({format: "yyyy-mm-dd"});
|
|
||||||
|
|
||||||
// Load theme
|
|
||||||
var theme = localStorage.getItem("theme");
|
|
||||||
if (theme === null)
|
|
||||||
theme = "light";
|
|
||||||
|
|
||||||
updateTheme(theme);
|
|
||||||
});
|
|
@ -1,255 +0,0 @@
|
|||||||
var contentTable;
|
|
||||||
contentOptions = null;
|
|
||||||
channelResolveInterval = null;
|
|
||||||
|
|
||||||
async function initContentTable() {
|
|
||||||
contentOptions = await getTrackedContentOptions();
|
|
||||||
await initTable("#contentTabPane", "contentTable", loadContent, null, deleteSelectedContent, contentOptions);
|
|
||||||
|
|
||||||
contentTable = $("#contentTable").DataTable({
|
|
||||||
info: false,
|
|
||||||
paging: false,
|
|
||||||
ordering: false,
|
|
||||||
searching: false,
|
|
||||||
autoWidth: false,
|
|
||||||
order: [],
|
|
||||||
select: {
|
|
||||||
style: "multi+shift",
|
|
||||||
selector: 'th:first-child input[type="checkbox"]'
|
|
||||||
},
|
|
||||||
columnDefs: [
|
|
||||||
{ orderable: false, targets: "no-sort" },
|
|
||||||
{
|
|
||||||
targets: 0,
|
|
||||||
checkboxes: { selectRow: true }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
// Select row checkbox column
|
|
||||||
title: '<input type="checkbox" class="form-check-input table-select-all" />',
|
|
||||||
data: null,
|
|
||||||
orderable: false,
|
|
||||||
className: "text-center col-switch-width",
|
|
||||||
render: function() {
|
|
||||||
return '<input type="checkbox" class="form-check-input table-select-row" />'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ data: "id", visible: false },
|
|
||||||
{
|
|
||||||
title: "GUID",
|
|
||||||
data: "guid",
|
|
||||||
className: "text-truncate mw-10rem",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Name",
|
|
||||||
data: "title",
|
|
||||||
className: "text-truncate",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const title = sanitise(data);
|
|
||||||
const url = sanitise(row.url);
|
|
||||||
return `<a href="${url}" class="btn btn-link text-start text-decoration-none" target="_blank">${title}</a>`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Subscription",
|
|
||||||
data: "subscription.name",
|
|
||||||
className: "text-nowrap",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const subName = sanitise(data);
|
|
||||||
return `<button type="button" onclick="goToSubscription(${row.subscription.id})" class="btn btn-link text-start text-decoration-none">${subName}</button>`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Blocked",
|
|
||||||
data: "blocked",
|
|
||||||
className: "text-center col-1",
|
|
||||||
render: function(data) {
|
|
||||||
return data ? `<i class="bi bi-check-lg text-success"></i>` : ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Channel",
|
|
||||||
data: "channel_id",
|
|
||||||
className: "text-start",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const channelId = sanitise(data);
|
|
||||||
const messageId = sanitise(row.message_id);
|
|
||||||
return `<div class="resolve-channel-name text-center" data-channel-id="${channelId}" data-msg-id="${messageId}">
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Created",
|
|
||||||
data: "creation_datetime",
|
|
||||||
className: "text-nowrap",
|
|
||||||
render: function(data, type) {
|
|
||||||
let dateTime = new Date(data);
|
|
||||||
return $(`
|
|
||||||
<span data-bs-trigger="hover focus"
|
|
||||||
data-bs-html="true"
|
|
||||||
data-bs-custom-class="text-center"
|
|
||||||
data-bs-toggle="popover"
|
|
||||||
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
|
|
||||||
${formatStringDate(dateTime, "%D, %b %Y")}
|
|
||||||
</span>
|
|
||||||
`).popover()[0];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orderable: false,
|
|
||||||
className: "p-0",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const embedColour = sanitise(row.subscription.embed_colour);
|
|
||||||
return `<div class="h-100" style="background-color: #${embedColour}; width: .25rem;"> </div>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
bindTableCheckboxes("#contentTable", contentTable, "#contentTabPane .table-del-btn");
|
|
||||||
|
|
||||||
contentTable.on("draw", function() {
|
|
||||||
restartResolveChannelNamesTask();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// #region Resolve Channels
|
|
||||||
|
|
||||||
function restartResolveChannelNamesTask() {
|
|
||||||
clearInterval(channelResolveInterval);
|
|
||||||
startResolveChannelNamesTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startResolveChannelNamesTask() {
|
|
||||||
const guildId = getCurrentlyActiveServer().guild_id;
|
|
||||||
channelResolveInterval = setInterval(function() {
|
|
||||||
if (resolveChannelNames(guildId))
|
|
||||||
clearInterval(channelResolveInterval);
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveChannelNames(guildId) {
|
|
||||||
if (!discordChannels.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
$(".resolve-channel-name").each(function() {
|
|
||||||
const channelId = $(this).data("channel-id");
|
|
||||||
const messageId = $(this).data("msg-id");
|
|
||||||
console.log(channelId + " " + messageId);
|
|
||||||
const channel = discordChannels.find(channel => channel.value === channelId);
|
|
||||||
|
|
||||||
if (channel) {
|
|
||||||
const href = `https://discord.com/channels/${guildId}/${channelId}/${messageId}/`;
|
|
||||||
$(this).replaceWith(
|
|
||||||
$("<a>").text(channel.text)
|
|
||||||
.attr("href", href)
|
|
||||||
.attr("target", "_blank")
|
|
||||||
.addClass("btn btn-link text-start text-decoration-none text-nowrap")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
async function goToSubscription(subId) {
|
|
||||||
$("#subscriptionsTab").click();
|
|
||||||
await showEditSubModal(subId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// #region Delete Content
|
|
||||||
|
|
||||||
async function deleteSelectedContent() {
|
|
||||||
const rows = contentTable.rows(".selected").data().toArray();
|
|
||||||
const names = rows.map(row => { return row.title });
|
|
||||||
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
|
|
||||||
const isMany = names.length > 1;
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
`Delete ${isMany ? "Many Tracked Contents" : "a Tracked Content"}`,
|
|
||||||
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> Tracked Content${isMany ? "s" : ""}?<br><br>${namesString}`,
|
|
||||||
"danger",
|
|
||||||
async () => {
|
|
||||||
rows.forEach(async row => { await deleteTrackedContent(row.id) });
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
`Deleted ${names.length} Content${isMany ? "s" : ""}`,
|
|
||||||
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
|
|
||||||
12000
|
|
||||||
);
|
|
||||||
|
|
||||||
// Multi-deletion can take time, this timeout ensures the refresh is accurate
|
|
||||||
setTimeout(async () => {
|
|
||||||
await loadContent(getCurrentlyActiveServer().guild_id);
|
|
||||||
}, 600);
|
|
||||||
},
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
|
|
||||||
function clearExistingContentRows() {
|
|
||||||
$("#contentTable thead .table-select-all").prop("checked", false).prop("indeterminate", false);
|
|
||||||
contentTable.clear().draw(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#contentTabPane").on("click", ".table-refresh-btn", async function() {
|
|
||||||
await loadContent(getCurrentlyActiveServer().guild_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// #region Load Content
|
|
||||||
|
|
||||||
async function loadContent(guildId) {
|
|
||||||
|
|
||||||
if (!guildId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setTableFilter("contentTable", "subscription__guild_id", guildId);
|
|
||||||
ensureTablePagination("contentTable");
|
|
||||||
|
|
||||||
$("#contentTabPane .table-del-btn").prop("disabled", true);
|
|
||||||
clearExistingContentRows();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var content = await getTrackedContent(tableFilters["contentTable"], tableSorts["contentTable"]);
|
|
||||||
contentTable.rows.add(content.results).draw(false);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showToast("danger", `Error loading Tracked Content: HTTP ${err.status}`, err, 15000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTableContainer(
|
|
||||||
"contentTabPane",
|
|
||||||
tableFilters["contentTable"]["page"],
|
|
||||||
tableFilters["contentTable"]["page_size"],
|
|
||||||
content.results.length,
|
|
||||||
content.count,
|
|
||||||
content.next,
|
|
||||||
content.previous
|
|
||||||
);
|
|
||||||
|
|
||||||
$("#contentTable thead .table-select-all").prop("disabled", content.results.length === 0);
|
|
||||||
console.debug(`loaded filters, ${content.results.length} found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).on("selectedServerChange", async function() {
|
|
||||||
const activeServer = getCurrentlyActiveServer();
|
|
||||||
await loadContent(activeServer.guild_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #endregion
|
|
@ -1,311 +0,0 @@
|
|||||||
var filtersTable;
|
|
||||||
filterOptions = null;
|
|
||||||
|
|
||||||
// Create filters table
|
|
||||||
async function initFiltersTable() {
|
|
||||||
filterOptions = await getFilterOptions();
|
|
||||||
await initTable("#filtersTabPane", "filtersTable", loadFilters, showEditFilterModal, deleteSelectedFilters, filterOptions);
|
|
||||||
|
|
||||||
filtersTable = $("#filtersTable").DataTable({
|
|
||||||
info: false,
|
|
||||||
paging: false,
|
|
||||||
ordering: false,
|
|
||||||
searching: false,
|
|
||||||
autoWidth: false,
|
|
||||||
order: [],
|
|
||||||
select: {
|
|
||||||
style: "multi+shift",
|
|
||||||
selector: 'th:first-child input[type="checkbox"]'
|
|
||||||
},
|
|
||||||
columnDefs: [
|
|
||||||
{ orderable: false, targets: "no-sort" },
|
|
||||||
{
|
|
||||||
targets: 0,
|
|
||||||
checkboxes: { selectRow: true }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
// Select row checkbox column
|
|
||||||
title: '<input type="checkbox" class="form-check-input table-select-all" />',
|
|
||||||
data: null,
|
|
||||||
orderable: false,
|
|
||||||
className: "text-center col-switch-width",
|
|
||||||
render: function() {
|
|
||||||
return '<input type="checkbox" class="form-check-input table-select-row" />'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ title: "ID", data: "id", visible: false },
|
|
||||||
{
|
|
||||||
title: "Name",
|
|
||||||
data: "name",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const name = sanitise(data);
|
|
||||||
return `<button type="button" onclick="showEditFilterModal(${row.id})" class="btn btn-link text-start text-decoration-none">${name}</button>`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Matching Algorithm",
|
|
||||||
data: "matching_algorithm",
|
|
||||||
render: function(data) {
|
|
||||||
switch (data) {
|
|
||||||
case 1: return "Any Word";
|
|
||||||
case 2: return "All Words";
|
|
||||||
case 3: return "Exact Match";
|
|
||||||
case 4: return "Regular Expression";
|
|
||||||
case 5: return "Fuzzy Match";
|
|
||||||
default:
|
|
||||||
console.error(`unknown matching algorithm '${data}'`);
|
|
||||||
return sanitise(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Match",
|
|
||||||
data: "match"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Case Sensitivity",
|
|
||||||
data: "is_insensitive",
|
|
||||||
render: function(data) {
|
|
||||||
return data ? "Insensitive" : "Sensitive"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Control List",
|
|
||||||
data: "is_whitelist",
|
|
||||||
render: function(data) {
|
|
||||||
return data ? "Whitelist" : "Blacklist";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
bindTableCheckboxes("#filtersTable", filtersTable, "#filtersTabPane .table-del-btn");
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#addFilterBtn").on("click", async function() {
|
|
||||||
await showEditFilterModal(-1);
|
|
||||||
})
|
|
||||||
|
|
||||||
async function showEditFilterModal(filterId) {
|
|
||||||
|
|
||||||
if (filterId === -1) {
|
|
||||||
$("#filterFormModal input, #filterFormModal textarea").val("");
|
|
||||||
$("#filterFormModal input:checkbox").prop("checked", false);
|
|
||||||
|
|
||||||
$("#filterAlgorithm").val("").change();
|
|
||||||
|
|
||||||
$("#filterFormModal .form-create").show();
|
|
||||||
$("#filterFormModal .form-edit").hide();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const filter = filtersTable.row(function(idx, data, node) {
|
|
||||||
return data.id === filterId;
|
|
||||||
}).data();
|
|
||||||
|
|
||||||
$("#filterAlgorithm").val("").change();
|
|
||||||
$("#filterAlgorithm").val(filter.matching_algorithm).change();
|
|
||||||
|
|
||||||
$("#filterName").val(filter.name);
|
|
||||||
$("#filterMatch").val(filter.match);
|
|
||||||
$("#filterWhitelist").prop("checked", filter.is_whitelist);
|
|
||||||
$("#filterInsensitive").prop("checked", filter.is_insensitive);
|
|
||||||
|
|
||||||
$("#filterFormModal .form-create").hide();
|
|
||||||
$("#filterFormModal .form-edit").show();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#filterId").val(filterId);
|
|
||||||
$("#filterFormModal").modal("show");
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#filterForm").on("submit", async function(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
var id = $("#filterId").val();
|
|
||||||
name = $("#filterName").val();
|
|
||||||
algorithm = $("#filterAlgorithm option:selected").val();
|
|
||||||
match = $("#filterMatch").val();
|
|
||||||
isWhitelist = $("#filterWhitelist").prop("checked");
|
|
||||||
isInsensitive = $("#filterInsensitive").prop("checked");
|
|
||||||
guildId = getCurrentlyActiveServer().guild_id;
|
|
||||||
|
|
||||||
var filterPrimaryKey = await saveFilter(id, name, algorithm, match, isWhitelist, isInsensitive, guildId);
|
|
||||||
|
|
||||||
if (filterPrimaryKey) {
|
|
||||||
showToast("success", "Filter Saved", "Filter ID " + filterPrimaryKey);
|
|
||||||
await loadFilters(guildId);
|
|
||||||
await loadFilterOptions(guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#filterFormModal").modal("hide");
|
|
||||||
});
|
|
||||||
|
|
||||||
async function saveFilter(id, name, algorithm, match, isWhitelist, isInsensitive, guildId) {
|
|
||||||
var formData = new FormData();
|
|
||||||
formData.append("name", name);
|
|
||||||
formData.append("matching_algorithm", algorithm);
|
|
||||||
formData.append("match", match);
|
|
||||||
formData.append("is_whitelist", isWhitelist);
|
|
||||||
formData.append("is_insensitive", isInsensitive);
|
|
||||||
formData.append("guild_id", guildId);
|
|
||||||
|
|
||||||
var response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (id === "-1") response = await newFilter(formData);
|
|
||||||
else response = await editFilter(id, formData);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
showToast("danger", "Filter Error", err.responseText, 18000);
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearExistingFilterRows() {
|
|
||||||
$("#filtersTable thead .table-select-all").prop("checked", false).prop("indeterminate", false)
|
|
||||||
filtersTable.clear().draw(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#filtersTabPane").on("click", ".table-refresh-btn", async function() {
|
|
||||||
loadFilters(getCurrentlyActiveServer().guild_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadFilters(guildId) {
|
|
||||||
if (!guildId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setTableFilter("filtersTable", "guild_id", guildId);
|
|
||||||
ensureTablePagination("filtersTable");
|
|
||||||
|
|
||||||
$("#filtersTabPane .table-del-btn").prop("disabled", true);
|
|
||||||
clearExistingFilterRows();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var contentFilters = await getFilters(tableFilters["filtersTable"], tableSorts["filtersTable"]);
|
|
||||||
filtersTable.rows.add(contentFilters.results).draw(false);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
showToast("danger", `Error Loading Filters: HTTP ${err.status}`, err, 15000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTableContainer(
|
|
||||||
"filtersTabPane",
|
|
||||||
tableFilters["filtersTable"]["page"],
|
|
||||||
tableFilters["filtersTable"]["page_size"],
|
|
||||||
contentFilters.results.length,
|
|
||||||
contentFilters.count,
|
|
||||||
contentFilters.next,
|
|
||||||
contentFilters.previous
|
|
||||||
);
|
|
||||||
|
|
||||||
$("#filtersTable thead .table-select-all").prop("disabled", contentFilters.results.length === 0);
|
|
||||||
console.debug(`loaded filters, ${contentFilters.results.length} found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(async function() {
|
|
||||||
await loadMatchingAlgorithms();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadMatchingAlgorithms() {
|
|
||||||
// Disable input while options are loading
|
|
||||||
$("#filterAlgorithm").prop("disabled", true);
|
|
||||||
|
|
||||||
// Delete existing options
|
|
||||||
$("#filterAlgorithm option").each(function() {
|
|
||||||
if ($(this).val())
|
|
||||||
$(this).remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear select2 input
|
|
||||||
$("#filterAlgorithm").val("").change();
|
|
||||||
|
|
||||||
try {
|
|
||||||
options = await getFilterOptions();
|
|
||||||
options.actions.POST.matching_algorithm.choices.forEach(algorithm => {
|
|
||||||
$("#filterAlgorithm").append($("<option>", {
|
|
||||||
text: algorithm.display_name,
|
|
||||||
value: algorithm.value > 0 ? algorithm.value : "" // empty string for 'None' option at 0
|
|
||||||
})); // (helps with validation)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable the input
|
|
||||||
$("#filterAlgorithm").prop("disabled", false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$(document).on("selectedServerChange", async function() {
|
|
||||||
const activeServer = getCurrentlyActiveServer();
|
|
||||||
await loadFilters(activeServer.guild_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #region Delete Filters
|
|
||||||
|
|
||||||
$("#deleteEditFilter").on("click", async function() {
|
|
||||||
const filterId = parseInt($("#filterId").val());
|
|
||||||
const filter = filtersTable.row(function(idx, row) { return row.id === filterId }).data();
|
|
||||||
const filterName = sanitise(filter.name);
|
|
||||||
|
|
||||||
$("#filterFormModal").modal("hide");
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
"Delete a Filter",
|
|
||||||
`Do you wish to permanently delete <b>${filterName}</b>?`,
|
|
||||||
"danger",
|
|
||||||
async () => {
|
|
||||||
await deleteFilter(filterId);
|
|
||||||
await loadFilters(getCurrentlyActiveServer().guild_id);
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
"Deleted a Filter",
|
|
||||||
filterName,
|
|
||||||
12000
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
$("#filterFormModal").modal("show");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteSelectedFilters() {
|
|
||||||
const rows = filtersTable.rows(".selected").data().toArray();
|
|
||||||
const names = rows.map(row => row.name);
|
|
||||||
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
|
|
||||||
const isMany = names.length > 1;
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
`Delete ${isMany ? "Many Filters" : "a Filter"}`,
|
|
||||||
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> filter${isMany ? "s" : ""}?<br><br>${namesString}`,
|
|
||||||
"danger",
|
|
||||||
async () => {
|
|
||||||
rows.forEach(async row => { await deleteFilter(row.id) });
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
`Delete ${names.length} Subscription${isMany ? "s" : ""}`,
|
|
||||||
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
|
|
||||||
12000
|
|
||||||
);
|
|
||||||
|
|
||||||
// Multi-deletion can take time, this timeout ensures the refresh is accurate
|
|
||||||
setTimeout(async () => {
|
|
||||||
await loadFilters(getCurrentlyActiveServer().guild_id);
|
|
||||||
}, 600);
|
|
||||||
},
|
|
||||||
null
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
@ -1,313 +0,0 @@
|
|||||||
|
|
||||||
// #region Loaded Servers
|
|
||||||
|
|
||||||
var loadedServers = {};
|
|
||||||
|
|
||||||
// Returns the currently active server, or null if none are active.
|
|
||||||
function getCurrentlyActiveServer() {
|
|
||||||
const activeServerAndId = Object.entries(loadedServers).find(([id, server]) => server.currentlyActive);
|
|
||||||
if (activeServerAndId === undefined)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var [id, activeServer] = activeServerAndId;
|
|
||||||
activeServer.id = id;
|
|
||||||
|
|
||||||
return activeServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the requested server from the provided snowflake id
|
|
||||||
function getServerFromSnowflake(guildId) {
|
|
||||||
const serverAndId = Object.entries(loadedServers).find(([id, server]) => server.guild_id == guildId);
|
|
||||||
if (serverAndId === undefined)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var [id, server] = serverAndId;
|
|
||||||
server.id = id;
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToLoadedServers(server, selectNew=true) {
|
|
||||||
// Remove the 'id' property and add the 'currentlyActive' property
|
|
||||||
({id, ...rest} = server, server = {...rest, currentlyActive: false})
|
|
||||||
|
|
||||||
// Save the server as loaded
|
|
||||||
loadedServers[id] = server;
|
|
||||||
|
|
||||||
// Display the loaded server
|
|
||||||
addServerTemplate(id, sanitise(server.guild_id), sanitise(server.name), sanitise(server.icon), sanitise(server.permissions), sanitise(server.owner));
|
|
||||||
|
|
||||||
// Select the newly added server
|
|
||||||
if (selectNew) {
|
|
||||||
selectServer(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFromLoadedServers(serverPrimaryKey) {
|
|
||||||
delete loadedServers[serverPrimaryKey];
|
|
||||||
removeServerTemplate(serverPrimaryKey);
|
|
||||||
|
|
||||||
$("#backToSelectServer").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region Server Back Btn
|
|
||||||
|
|
||||||
$("#backToSelectServer").on("click", function() {
|
|
||||||
$("#noSelectedServer").show();
|
|
||||||
$("#selectedServerContainer").hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region Server Modal
|
|
||||||
|
|
||||||
$("#serverOptionsRefreshBtn").on("click", async function() {
|
|
||||||
await loadServerOptions();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load server options into the 'Add Server' dropdown
|
|
||||||
async function loadServerOptions() {
|
|
||||||
|
|
||||||
// Disable controls while loading
|
|
||||||
$("#serverOptions").prop("disabled", true);
|
|
||||||
$("#serverOptionsRefreshBtn").prop("disabled", true).find("i.bi").addClass("spinning-360");
|
|
||||||
|
|
||||||
// Remove existing options
|
|
||||||
$("#serverOptions option").each(function() {
|
|
||||||
if ($(this).val()) {
|
|
||||||
$(this).remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deselect any selected option
|
|
||||||
$("#serverOptions").val(null).trigger("change");
|
|
||||||
|
|
||||||
// Fetch and append the server options
|
|
||||||
try {
|
|
||||||
const servers = await loadGuilds();
|
|
||||||
servers.forEach(server => {
|
|
||||||
$("#serverOptions").append($("<option>", {
|
|
||||||
value: server.id,
|
|
||||||
text: sanitise(server.name),
|
|
||||||
"data-icon": sanitise(server.icon),
|
|
||||||
"data-permissions": sanitise(server.permissions),
|
|
||||||
"data-isowner": sanitise(server.owner)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(JSON.stringify(error, null, 4));
|
|
||||||
showToast("danger", `Error Loading Guilds: HTTP ${error.status}`, error.responseJSON.message, 15000);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable controls
|
|
||||||
$("#serverOptions").prop("disabled", false);
|
|
||||||
$("#serverOptionsRefreshBtn").prop("disabled", false).find("i.bi").removeClass("spinning-360");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region Server Sidebar
|
|
||||||
|
|
||||||
// Load any existing 'saved guilds' from the database
|
|
||||||
async function loadSavedGuilds() {
|
|
||||||
try {
|
|
||||||
const response = await getSavedGuilds();
|
|
||||||
|
|
||||||
response.forEach(server => {
|
|
||||||
|
|
||||||
// 'Register' the server, by storing it for later and
|
|
||||||
// displaying it on the server list sidebar
|
|
||||||
addToLoadedServers(server, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
alert("Error loading saved guilds: " + error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an element for the added server and show it
|
|
||||||
function addServerTemplate(serverPrimaryKey, serverGuildId, serverName, serverIconHash, serverPermissions, serverIsOwner) {
|
|
||||||
template = $($("#serverItemTemplate").html());
|
|
||||||
|
|
||||||
template.find("img").attr("src", `https://cdn.discordapp.com/icons/${serverGuildId}/${serverIconHash}.webp?size=80`);
|
|
||||||
template.attr("data-id", serverPrimaryKey);
|
|
||||||
|
|
||||||
// Tooltips
|
|
||||||
template.attr("data-bs-title", serverName);
|
|
||||||
template.tooltip();
|
|
||||||
|
|
||||||
// Bind the button for selecting this server
|
|
||||||
template.find(".server-item-selector").off("click").on("click", function() {
|
|
||||||
selectServer(serverPrimaryKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
$("#serverList").prepend(template);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeServerTemplate(serverPrimaryKey) {
|
|
||||||
$(`#serverList .server-item[data-id=${serverPrimaryKey}]`).remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open 'Add Server' Form Modal
|
|
||||||
$("#newServerBtn").on("click", function() {
|
|
||||||
newServerModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
function newServerModal() {
|
|
||||||
$("#serverFormModal").modal("show");
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region New Server
|
|
||||||
|
|
||||||
// Submit 'Add Server' Form
|
|
||||||
$("#serverForm").on("submit", async function(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
var selectedOption = $("#serverOptions option:selected");
|
|
||||||
serverName = selectedOption.text();
|
|
||||||
serverGuildId = selectedOption.val();
|
|
||||||
serverIconHash = selectedOption.attr("data-icon");
|
|
||||||
serverPermissions = selectedOption.attr("data-permissions");
|
|
||||||
serverIsOwner = selectedOption.attr("data-isowner");
|
|
||||||
|
|
||||||
var serverPrimaryKey = await registerNewServer(serverName, serverGuildId, serverIconHash, serverPermissions, serverIsOwner);
|
|
||||||
if (serverPrimaryKey)
|
|
||||||
addToLoadedServers(await getSavedGuild(serverPrimaryKey));
|
|
||||||
|
|
||||||
$("#serverFormModal").modal("hide");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a new 'saved guild' based on the info provided
|
|
||||||
// returns `response.id` if successful, else false
|
|
||||||
async function registerNewServer(serverName, serverGuildId, serverIconHash, serverPermissions, serverIsOwner) {
|
|
||||||
var formData = new FormData();
|
|
||||||
formData.append("name", serverName);
|
|
||||||
formData.append("guild_id", serverGuildId);
|
|
||||||
formData.append("icon", serverIconHash);
|
|
||||||
formData.append("added_by", currentUserId);
|
|
||||||
formData.append("permissions", serverPermissions);
|
|
||||||
formData.append("owner", serverIsOwner === "true");
|
|
||||||
|
|
||||||
try { response = await newSavedGuild(formData); }
|
|
||||||
catch (err) {
|
|
||||||
if (err.status === 409)
|
|
||||||
showToast("warning", "Server Conflict", `Can't add ${sanitise(serverName)} because it already exists.`, 10000);
|
|
||||||
else
|
|
||||||
console.error(JSON.stringify(err, null, 4));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region Select Server
|
|
||||||
|
|
||||||
function selectServer(primaryKey) {
|
|
||||||
var server = loadedServers[primaryKey];
|
|
||||||
|
|
||||||
// Change appearance of selected vs none-selected items
|
|
||||||
$("#serverList .server-item").removeClass("active")
|
|
||||||
$(`#serverList .server-item[data-id=${primaryKey}]`).addClass("active")
|
|
||||||
|
|
||||||
// Display details of the selected server
|
|
||||||
$("#selectedServerContainer .selected-server-name").text(sanitise(server.name));
|
|
||||||
$("#selectedServerContainer .selected-server-id").text(sanitise(server.guild_id));
|
|
||||||
$("#selectedServerContainer .selected-server-icon").attr("src", `https://cdn.discordapp.com/icons/${server.guild_id}/${server.icon}.webp?size=80`);
|
|
||||||
|
|
||||||
// Disable all loaded servers
|
|
||||||
$.each(loadedServers, function(serverPrimaryKey, server) {
|
|
||||||
server.currentlyActive = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate current selected server
|
|
||||||
loadedServers[primaryKey].currentlyActive = true;
|
|
||||||
|
|
||||||
$("#noSelectedServer").hide();
|
|
||||||
$("#selectedServerContainer").show().css("display", "flex");
|
|
||||||
|
|
||||||
$(document).trigger("selectedServerChange");
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// #region Delete Server Btn
|
|
||||||
|
|
||||||
$("#deleteSelectedServerBtn").on("click", async function() {
|
|
||||||
const notes = [
|
|
||||||
"No Subscriptions, Filters or Tracked Content will be deleted.",
|
|
||||||
"No data will be deleted for other users.",
|
|
||||||
"The server will no longer appear on your sidebar.",
|
|
||||||
"You can re-add the server",
|
|
||||||
"All Subscriptions, Filters and Tracked Content will be available when/if you re-add the server."
|
|
||||||
];
|
|
||||||
const notesString = arrayToHtmlList(notes).prop("outerHTML");
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
"Close this server?",
|
|
||||||
`This is a safe, non-permanent action:<br><br>${notesString}`,
|
|
||||||
"warning",
|
|
||||||
deleteSelectedServer,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteSelectedServer() {
|
|
||||||
var activeServer = getCurrentlyActiveServer();
|
|
||||||
|
|
||||||
if (!activeServer) {
|
|
||||||
showToast("danger", "Error Deleting Server", "You must select a server to delete.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`Deleting ${activeServer.id}: ${JSON.stringify(activeServer, null, 4)}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteSavedGuild(activeServer.id);
|
|
||||||
removeFromLoadedServers(activeServer.id);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
alert(error)
|
|
||||||
alert(JSON.stringify(error, null, 4))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
$(document).on("selectedServerChange", function() {
|
|
||||||
resolveServerStrings();
|
|
||||||
$("#serverJoinAlert").hide();
|
|
||||||
})
|
|
||||||
|
|
||||||
// #region Resolve Strings
|
|
||||||
|
|
||||||
function resolveServerStrings() {
|
|
||||||
const server = getCurrentlyActiveServer();
|
|
||||||
|
|
||||||
// Server names
|
|
||||||
$(".resolve-to-server-name").text(sanitise(server.name));
|
|
||||||
|
|
||||||
// Server Guild Ids
|
|
||||||
$(".resolve-to-server-id").text(sanitise(server.guild_id))
|
|
||||||
|
|
||||||
// Bot Invite links
|
|
||||||
$(".resolve-to-invite-link").attr("href", `https://discord.com/oauth2/authorize
|
|
||||||
?client_id=${discordClientId}
|
|
||||||
&permissions=2147534848
|
|
||||||
&scope=bot+applications.commands
|
|
||||||
&guild_id=${sanitise(server.guild_id)}
|
|
||||||
&disable_guild_select=true`);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
@ -1,59 +0,0 @@
|
|||||||
|
|
||||||
$("#serverSettingsBtn").on("click", async function() {
|
|
||||||
await showServerSettingsModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function showServerSettingsModal() {
|
|
||||||
const server = getCurrentlyActiveServer();
|
|
||||||
var guildSettings;
|
|
||||||
|
|
||||||
try { guildSettings = (await getGuildSettings(server.guild_id)).results[0] }
|
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#guildSettingsId").val(guildSettings.id);
|
|
||||||
$("#guildSettingsGuildId").val(guildSettings.guild_id);
|
|
||||||
$("#guildSettingsActive").prop("checked", guildSettings.active);
|
|
||||||
updateColourInput("guildSettingsDefaultEmbedColour", guildSettings.default_embed_colour);
|
|
||||||
|
|
||||||
$("#serverSettingsModal").modal("show");
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#serverSettingsForm").on("submit", async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
var id = $("#guildSettingsId").val();
|
|
||||||
guildId = $("#guildSettingsGuildId").val();
|
|
||||||
active = $("#guildSettingsActive").prop("checked");
|
|
||||||
defaultEmbedColour = getColourInputVal("guildSettingsDefaultEmbedColour", false);
|
|
||||||
|
|
||||||
const pk = await saveGuildSettings(id, guildId, defaultEmbedColour, active);
|
|
||||||
|
|
||||||
if (pk) {
|
|
||||||
showToast("success", "Server Settings Saved", "Primary Key: " + pk);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDefaultSubEmbedColour();
|
|
||||||
$("#serverSettingsModal").modal("hide");
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
async function saveGuildSettings(id, guildId, defaultEmbedColour, active) {
|
|
||||||
var formData = new FormData();
|
|
||||||
formData.append("guild_id", guildId);
|
|
||||||
formData.append("default_embed_colour", defaultEmbedColour);
|
|
||||||
formData.append("active", active);
|
|
||||||
|
|
||||||
var response;
|
|
||||||
try {
|
|
||||||
response = await editGuildSettings(id, formData);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.id;
|
|
||||||
}
|
|
@ -1,633 +0,0 @@
|
|||||||
var subTable = null;
|
|
||||||
discordChannels = [];
|
|
||||||
subSearchTimeout = null;
|
|
||||||
subOptions = null;
|
|
||||||
|
|
||||||
// Create subscription table
|
|
||||||
async function initSubscriptionTable() {
|
|
||||||
subOptions = await getSubscriptionOptions();
|
|
||||||
await initTable("#subscriptionsTabPane", "subTable", loadSubscriptions, showEditSubModal, deleteSelectedSubscriptions, subOptions);
|
|
||||||
|
|
||||||
subTable = $("#subTable").DataTable({
|
|
||||||
info: false,
|
|
||||||
paging: false,
|
|
||||||
ordering: false,
|
|
||||||
searching: false,
|
|
||||||
autoWidth: false,
|
|
||||||
order: [],
|
|
||||||
select: {
|
|
||||||
style: "multi+shift",
|
|
||||||
selector: 'th:first-child input[type="checkbox"]'
|
|
||||||
},
|
|
||||||
columnDefs: [
|
|
||||||
{ orderable: false, targets: "no-sort" },
|
|
||||||
{
|
|
||||||
targets: 0,
|
|
||||||
checkboxes: { selectRow: true }
|
|
||||||
},
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
// Select row checkbox column
|
|
||||||
title: '<input type="checkbox" class="form-check-input table-select-all" />',
|
|
||||||
data: null,
|
|
||||||
orderable: false,
|
|
||||||
className: "text-center col-switch-width",
|
|
||||||
render: function() {
|
|
||||||
return '<input type="checkbox" class="form-check-input table-select-row" />'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ title: "ID", data: "id", visible: false },
|
|
||||||
{
|
|
||||||
title: "Name",
|
|
||||||
data: "name",
|
|
||||||
className: "text-truncate",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const name = sanitise(data);
|
|
||||||
return `<button type="button" onclick="showEditSubModal(${row.id})" class="btn btn-link text-start text-decoration-none">${name}</button>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "URL",
|
|
||||||
data: "url",
|
|
||||||
className: "text-truncate",
|
|
||||||
render: function(data, type) {
|
|
||||||
const url = sanitise(data);
|
|
||||||
return `<a href="${url}" class="btn btn-link text-start text-decoration-none" target="_blank">${url}</a>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Channels",
|
|
||||||
data: "channels_count",
|
|
||||||
className: "text-center",
|
|
||||||
render: function(data) {
|
|
||||||
const channelsCount = sanitise(data);
|
|
||||||
return `<span class="badge text-bg-secondary">${channelsCount}</span>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Created",
|
|
||||||
data: "creation_datetime",
|
|
||||||
render: function(data, type) {
|
|
||||||
let dateTime = new Date(data);
|
|
||||||
return $(`
|
|
||||||
<span data-bs-trigger="hover focus"
|
|
||||||
data-bs-html="true"
|
|
||||||
data-bs-custom-class="text-center"
|
|
||||||
data-bs-toggle="popover"
|
|
||||||
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
|
|
||||||
${formatStringDate(dateTime, "%D, %b %Y")}
|
|
||||||
</span>
|
|
||||||
`).popover()[0];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Notes",
|
|
||||||
data: "extra_notes",
|
|
||||||
orderable: false,
|
|
||||||
className: "text-center",
|
|
||||||
render: function(data, type) {
|
|
||||||
if (!data) { return "" }
|
|
||||||
const extraNotes = sanitise(data);
|
|
||||||
return $(`
|
|
||||||
<i class="bi bi-chat-left-text"
|
|
||||||
data-bs-trigger="hover focus"
|
|
||||||
data-bs-toggle="popover"
|
|
||||||
data-bs-title="Extra Notes"
|
|
||||||
data-bs-content="${extraNotes}">
|
|
||||||
</i>
|
|
||||||
`).popover()[0];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active",
|
|
||||||
data: "active",
|
|
||||||
orderable: false,
|
|
||||||
className: "text-center form-switch",
|
|
||||||
render: function(data, type) {
|
|
||||||
return `<input type="checkbox" class="sub-toggle-active form-check-input ms-0" ${data ? "checked" : ""} />`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orderable: false,
|
|
||||||
className: "p-0",
|
|
||||||
render: function(data, type, row) {
|
|
||||||
const embedColour = sanitise(row.embed_colour);
|
|
||||||
return `<div class="h-100" style="background-color: #${embedColour}; width: .25rem;"> </div>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
bindTableCheckboxes("#subTable", subTable, "#subscriptionsTabPane .table-del-btn");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSubFromObject(sub, handleErrorMsg=true) {
|
|
||||||
let data = {
|
|
||||||
"name": sub.name,
|
|
||||||
"url": sub.url,
|
|
||||||
"guild_id": sub.guild_id,
|
|
||||||
"extra_notes": sub.extra_notes,
|
|
||||||
"embed_colour": sub.embed_colour,
|
|
||||||
"article_fetch_image": sub.article_fetch_image,
|
|
||||||
"published_threshold": sub.published_threshold,
|
|
||||||
"active": sub.active
|
|
||||||
};
|
|
||||||
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
for (key in data) {
|
|
||||||
formData.append(key, data[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.article_title_mutators.forEach(mutator => formData.append("article_title_mutators", mutator.id));
|
|
||||||
sub.article_desc_mutators.forEach(mutator => formData.append("article_desc_mutators", mutator.id));
|
|
||||||
sub.filters.forEach(filter => formData.append("filters", filter));
|
|
||||||
|
|
||||||
return await saveSubscription(sub.id, formData, handleErrorMsg=handleErrorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#subscriptionsTabPane").on("change", ".sub-toggle-active", async function () {
|
|
||||||
|
|
||||||
/*
|
|
||||||
Lock all toggles to soft-prevent spam.
|
|
||||||
There is a rate limit, but allowing the user to
|
|
||||||
reach it from this toggle would be bad.
|
|
||||||
*/
|
|
||||||
$(".sub-toggle-active").prop("disabled", true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const active = $(this).prop("checked");
|
|
||||||
const sub = subTable.row($(this).closest("tr")).data();
|
|
||||||
|
|
||||||
// Update the table row
|
|
||||||
sub.active = active;
|
|
||||||
subTable.data(sub).draw();
|
|
||||||
|
|
||||||
// Update the database
|
|
||||||
const subId = await updateSubFromObject(sub, handleErrorMsg=false);
|
|
||||||
|
|
||||||
if (!subId) {
|
|
||||||
throw Error("This subscription no longer exists.");
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
active ? "success" : "danger",
|
|
||||||
"Subscription " + (active ? "Activated" : "Deactivated"),
|
|
||||||
"Subscription ID: " + subId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
"Error Updating Subscription",
|
|
||||||
`Tried to toggle activeness, but encountered a problem. <br><code>${error}</code>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable toggles after 500ms
|
|
||||||
setTimeout(() => {
|
|
||||||
$(".sub-toggle-active").prop("disabled", false); },
|
|
||||||
500
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open new subscription modal
|
|
||||||
$("#addSubscriptionBtn").on("click", async function() {
|
|
||||||
await showEditSubModal(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function showEditSubModal(subId) {
|
|
||||||
if (subId === -1) {
|
|
||||||
$("#subFormModal .form-create, #subAdvancedModal .form-create").show();
|
|
||||||
$("#subFormModal .form-edit, #subAdvancedModal .form-edit").hide();
|
|
||||||
|
|
||||||
$("#subFormModal input, #subFormModal textarea").val("");
|
|
||||||
$("#subChannels").val("").change();
|
|
||||||
$("#subFilters").val("").change();
|
|
||||||
$("#subTitleMutators").val("").change();
|
|
||||||
$("#subDescMutators").val("").change();
|
|
||||||
$("#subActive").prop("checked", true);
|
|
||||||
|
|
||||||
$("#subEmbedColour .colour-reset").click();
|
|
||||||
$("#subArticleFetchImage").prop("checked", true);
|
|
||||||
|
|
||||||
$("#subPubThreshold").val(getCurrentDateTime());
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$("#subFormModal .form-create, #subAdvancedModal .form-create").hide();
|
|
||||||
$("#subFormModal .form-edit, #subAdvancedModal .form-edit").show();
|
|
||||||
|
|
||||||
const subscription = subTable.row(function(idx, data, node) {
|
|
||||||
return data.id === subId;
|
|
||||||
}).data();
|
|
||||||
|
|
||||||
$("#subName").val(subscription.name);
|
|
||||||
$("#subUrl").val(subscription.url);
|
|
||||||
$("#subExtraNotes").val(subscription.extra_notes);
|
|
||||||
$("#subActive").prop("checked", subscription.active);
|
|
||||||
|
|
||||||
$("#subTitleMutators").val("").change();
|
|
||||||
$("#subTitleMutators").val(subscription.article_title_mutators.map(mutator => mutator.id)).change();
|
|
||||||
|
|
||||||
$("#subDescMutators").val("").change();
|
|
||||||
$("#subDescMutators").val(subscription.article_desc_mutators.map(mutator => mutator.id)).change();
|
|
||||||
|
|
||||||
const channels = await getSubChannels(subscription.id);
|
|
||||||
$("#subChannels").val("").change();
|
|
||||||
$("#subChannels").val(channels.results.map(channel => channel.channel_id)).change();
|
|
||||||
|
|
||||||
$("#subFilters").val("").change();
|
|
||||||
$("#subFilters").val(subscription.filters).change();
|
|
||||||
|
|
||||||
updateColourInput("subEmbedColour", `#${subscription.embed_colour}`);
|
|
||||||
$("#subArticleFetchImage").prop("checked", subscription.article_fetch_image);
|
|
||||||
|
|
||||||
$("#subPubThreshold").val(subscription.published_threshold.split('+')[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#subId").val(subId);
|
|
||||||
$("#subFormModal").modal("show");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValueFromField(elem) {
|
|
||||||
const tagName = elem.tagName.toLowerCase();
|
|
||||||
const $elem = $(elem);
|
|
||||||
|
|
||||||
if (tagName) { return $elem.val() }
|
|
||||||
|
|
||||||
switch ($elem.attr("type")) {
|
|
||||||
case "checkbox":
|
|
||||||
return $elem.prop("checked");
|
|
||||||
|
|
||||||
default:
|
|
||||||
return $elem.val();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#subForm").on("submit", async function(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
let subId = $("#subId").val();
|
|
||||||
let guildId = getCurrentlyActiveServer().guild_id;
|
|
||||||
|
|
||||||
// TODO: move this into a function, so I can fix the active toggle switches which are broken due to this change
|
|
||||||
|
|
||||||
let formData = new FormData();
|
|
||||||
formData.append("guild_id", guildId);
|
|
||||||
|
|
||||||
// Populate formdata with [data-field] control values
|
|
||||||
$('#subForm [data-field], #subAdvancedModal [data-field]').each(function() {
|
|
||||||
const value = getValueFromField(this);
|
|
||||||
formData.append($(this).data("field"), value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add title mutators to formdata
|
|
||||||
$("#subTitleMutators option:selected").toArray().map(mutator => parseInt(mutator.value)).forEach(
|
|
||||||
mutator => formData.append("article_title_mutators", mutator)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add description mutator to formdata
|
|
||||||
$("#subDescMutators option:selected").toArray().map(mutator => parseInt(mutator.value)).forEach(
|
|
||||||
mutator => formData.append("article_desc_mutators", mutator)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add Filters to formdata
|
|
||||||
$("#subFilters option:selected").toArray().forEach(
|
|
||||||
filter => formData.append("filters", parseInt(filter.value))
|
|
||||||
);
|
|
||||||
|
|
||||||
// This field is constructed differently, so needs to be specifically added
|
|
||||||
formData.append("embed_colour", getColourInputVal("subEmbedColour", false));
|
|
||||||
|
|
||||||
|
|
||||||
subId = await saveSubscription(subId, formData);
|
|
||||||
|
|
||||||
if (subId) {
|
|
||||||
showToast("success", "Subscription Saved", `Subscription ID ${subId}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
showToast("danger", "Error Saving Subscription", "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteSubChannels(subId);
|
|
||||||
$("#subChannels option:selected").each(async function() {
|
|
||||||
let $channel = $(this);
|
|
||||||
let channelFormData = new FormData();
|
|
||||||
channelFormData.append("channel_id", $channel.val());
|
|
||||||
channelFormData.append("channel_name", $channel.data("name"));
|
|
||||||
channelFormData.append("subscription", subId);
|
|
||||||
await newSubChannel(channelFormData);
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadSubscriptions(guildId);
|
|
||||||
$("#subFormModal").modal("hide");
|
|
||||||
});
|
|
||||||
|
|
||||||
async function saveSubscription(id, formData, handleErrorMsg=true) {
|
|
||||||
let response
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = id === "-1" ? await newSubscription(formData) : await editSubscription(id, formData);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
if (handleErrorMsg) {
|
|
||||||
showToast("danger", "Subscription Error", err.responseText, 18000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSubChannel(formData) {
|
|
||||||
var response
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await newSubChannel(formData);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
showToast("danger", "Failed to save subchannel", error, 18000);
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.id
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearExistingSubRows() {
|
|
||||||
$("#subTable thead .table-select-all").prop("checked", false).prop("indeterminate", false);
|
|
||||||
subTable.clear().draw(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#subscriptionsTabPane").on("click", ".table-refresh-btn", async function() {
|
|
||||||
loadSubscriptions(getCurrentlyActiveServer().guild_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadSubscriptions(guildId) {
|
|
||||||
if (!guildId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setTableFilter("subTable", "guild_id", guildId);
|
|
||||||
ensureTablePagination("subTable");
|
|
||||||
|
|
||||||
$("#subscriptionsTabPane .table-del-btn").prop("disabled", true);
|
|
||||||
clearExistingSubRows();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var subs = await getSubscriptions(tableFilters["subTable"], tableSorts["subTable"]);
|
|
||||||
subTable.rows.add(subs.results).draw(false);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
showToast("danger", `Error Loading Subscriptions: HTTP ${err.status}`, err, 15000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTableContainer(
|
|
||||||
"subscriptionsTabPane",
|
|
||||||
tableFilters["subTable"]["page"],
|
|
||||||
tableFilters["subTable"]["page_size"],
|
|
||||||
subs.results.length,
|
|
||||||
subs.count,
|
|
||||||
subs.next,
|
|
||||||
subs.previous
|
|
||||||
);
|
|
||||||
|
|
||||||
$("#subTable thead .table-select-all").prop("disabled", subs.results.length === 0);
|
|
||||||
console.debug("loading subs, " + subs.results.length + " found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// #region Server Change Event Handler
|
|
||||||
|
|
||||||
$(document).on("selectedServerChange", async function() {
|
|
||||||
let server = getCurrentlyActiveServer();
|
|
||||||
guildId = server.guild_id;
|
|
||||||
|
|
||||||
await updateDefaultSubEmbedColour();
|
|
||||||
|
|
||||||
await loadSubscriptions(guildId);
|
|
||||||
await loadChannelOptions(guildId);
|
|
||||||
await loadFilterOptions(guildId);
|
|
||||||
await loadMutatorOptions();
|
|
||||||
})
|
|
||||||
|
|
||||||
async function updateDefaultSubEmbedColour(settings=null) {
|
|
||||||
if (!settings){
|
|
||||||
settings = (await getGuildSettings(guildId)).results[0]
|
|
||||||
}
|
|
||||||
$("#subEmbedColour .colour-reset").attr("data-defaultcolour", "#" + settings.default_embed_colour);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
|
|
||||||
// #region Delete Subscriptions
|
|
||||||
|
|
||||||
// Delete button on the 'edit subscription' modal
|
|
||||||
$("#deleteEditSub").on("click", async function() {
|
|
||||||
const subId = parseInt($("#subId").val());
|
|
||||||
const sub = subTable.row(function(idx, row) { return row.id === subId }).data();
|
|
||||||
const subName = sanitise(sub.name);
|
|
||||||
|
|
||||||
$("#subFormModal").modal("hide");
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
"Delete a Subscription",
|
|
||||||
`Do you wish to permanently delete <b>${subName}</b>?`,
|
|
||||||
"danger",
|
|
||||||
async () => {
|
|
||||||
await deleteSubscription(subId);
|
|
||||||
await loadSubscriptions(getCurrentlyActiveServer().guild_id);
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
"Deleted a Subscription",
|
|
||||||
subName,
|
|
||||||
12000
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
$("#subFormModal").modal("show");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteSelectedSubscriptions() {
|
|
||||||
const rows = subTable.rows(".selected").data().toArray();
|
|
||||||
const names = rows.map(row => row.name);
|
|
||||||
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
|
|
||||||
const isMany = names.length > 1;
|
|
||||||
|
|
||||||
await confirmationModal(
|
|
||||||
`Delete ${isMany ? "Many Subscriptions" : "a Subscription"}`,
|
|
||||||
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> subscription${isMany ? "s" : ""}?<br><br>${namesString}`,
|
|
||||||
"danger",
|
|
||||||
async () => {
|
|
||||||
rows.forEach(async row => { await deleteSubscription(row.id) });
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
"danger",
|
|
||||||
`Deleted ${names.length} Subscription${isMany ? "s" : ""}`,
|
|
||||||
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
|
|
||||||
12000
|
|
||||||
);
|
|
||||||
|
|
||||||
// Multi-deletion can take time, this timeout ensures the refresh is accurate
|
|
||||||
setTimeout(async () => {
|
|
||||||
await loadSubscriptions(getCurrentlyActiveServer().guild_id);
|
|
||||||
}, 600);
|
|
||||||
},
|
|
||||||
null
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
|
|
||||||
// #region Load Modal Options
|
|
||||||
|
|
||||||
async function loadChannelOptions(guildId) {
|
|
||||||
|
|
||||||
// Disable input while options are loading
|
|
||||||
$("#subChannels").prop("disabled", true);
|
|
||||||
|
|
||||||
// Delete existing options
|
|
||||||
$("#subChannels option").each(function() {
|
|
||||||
if ($(this).val())
|
|
||||||
$(this).remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear select2 input
|
|
||||||
$("#subChannels").val("").change();
|
|
||||||
try {
|
|
||||||
const channels = await loadChannels(guildId);
|
|
||||||
|
|
||||||
// If we have reached the discord API rate limit
|
|
||||||
if (channels.message && channels.message.includes("rate limit")) {
|
|
||||||
throw new Error(
|
|
||||||
`${channels.message} Retry after ${channels.retry_after} seconds.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we can't fetch channels due to error
|
|
||||||
if (channels.code === 50001) {
|
|
||||||
|
|
||||||
// Also check that the user hasn't changed the currently active guild, otherwise
|
|
||||||
// the alert will show under the wrong server.
|
|
||||||
if (getCurrentlyActiveServer().guild_id === guildId)
|
|
||||||
$("#serverJoinAlert").show();
|
|
||||||
|
|
||||||
const guildName = sanitise(getServerFromSnowflake(guildId).name);
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Unable to retrieve channels from Guild <b>${guildName}</b>.
|
|
||||||
Ensure that @PYRSS is a member with permissions
|
|
||||||
to view channels.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by the specified position of each channel object
|
|
||||||
channels.sort((a, b) => a.position - b.position);
|
|
||||||
|
|
||||||
discordChannels = [];
|
|
||||||
channels.forEach(channel => {
|
|
||||||
|
|
||||||
// We only want TextChannels, which have a type of 0
|
|
||||||
if (channel.type !== 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let channelObj = {text: `#${channel.name}`, value: channel.id, "data-name": channel.name}
|
|
||||||
$("#subChannels").append($("<option>", channelObj));
|
|
||||||
discordChannels.push(channelObj);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
showToast("danger", "Error loading channels", error, 18000);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable the input
|
|
||||||
$("#subChannels").prop("disabled", false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMutatorOptions() {
|
|
||||||
|
|
||||||
// Disable input while options are loading
|
|
||||||
$(".sub-mutators-field").prop("disabled", true);
|
|
||||||
|
|
||||||
// Delete existing options
|
|
||||||
$(".sub-mutators-field option").each(function() {
|
|
||||||
if ($(this).val())
|
|
||||||
$(this).remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear select2 input
|
|
||||||
$(".sub-mutators-field").val("").change();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mutators = await getMutators();
|
|
||||||
console.log(JSON.stringify(mutators));
|
|
||||||
|
|
||||||
mutators.forEach(mutator => {
|
|
||||||
$(".sub-mutators-field").append($("<option>", {
|
|
||||||
text: mutator.name,
|
|
||||||
value: mutator.id
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
showToast("danger", "Error loading sub mutators", error, 18000);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable the input
|
|
||||||
$(".sub-mutators-field").prop("disabled", false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFilterOptions(guildId) {
|
|
||||||
|
|
||||||
// Disable input while options are loading
|
|
||||||
$("#subFilters").prop("disabled", true);
|
|
||||||
|
|
||||||
// Delete existing options
|
|
||||||
$("#subFilters option").each(function() {
|
|
||||||
if ($(this).val())
|
|
||||||
$(this).remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear select2 input
|
|
||||||
$("#subFilters").val("").change();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filters = await getFilters({guild_id: guildId});
|
|
||||||
console.log(JSON.stringify(filters));
|
|
||||||
|
|
||||||
filters.results.forEach(filter => {
|
|
||||||
$("#subFilters").append($("<option>", {
|
|
||||||
text: filter.name,
|
|
||||||
value: filter.id
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
showToast("danger", "Error loading sub filters", error, 18000);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Re-enable the input
|
|
||||||
$("#subFilters").prop("disabled", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// #endregion
|
|
@ -1,27 +0,0 @@
|
|||||||
{% extends "layouts/base.html" %}
|
|
||||||
|
|
||||||
{% block title %} Blank Page {% endblock title %}
|
|
||||||
|
|
||||||
<!-- Specific CSS goes HERE -->
|
|
||||||
{% block stylesheets %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/home/main.css' %}">
|
|
||||||
<link rel="stylesheet" href="{% static '/css/select2-bootstrap.min.css' %}">
|
|
||||||
{% endblock stylesheets %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<!-- ### $App Screen Content ### -->
|
|
||||||
<main class='main-content bg-body-tertiary'>
|
|
||||||
<div id='mainContent'>
|
|
||||||
<div class="full-container">
|
|
||||||
|
|
||||||
<h1>Add content here</h1>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
||||||
<!-- Specific Page JS goes HERE -->
|
|
||||||
{% block javascripts %}{% endblock javascripts %}
|
|
@ -1,16 +0,0 @@
|
|||||||
<div id="confirmationModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content rounded-1">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title mx-2"></h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<p class="mb-0"></p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer px-4">
|
|
||||||
<button type="button" class="btn rounded-1 modal-confirm-btn" tabindex="1">Confirm</button>
|
|
||||||
<button type="button" class="btn btn-secondary rounded-1 ms-3 ms-0 modal-dismiss-btn" tabindex="2">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,61 +0,0 @@
|
|||||||
|
|
||||||
<div id="filterFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content rounded-1">
|
|
||||||
<form id="filterForm" class="mb-0" novalidate>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title ms-2">
|
|
||||||
<span class="form-create">Add</span>
|
|
||||||
<span class="form-edit">Edit</span>
|
|
||||||
Filter
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<input type="hidden" id="filterId" name="filterId">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="filterName" class="form-label">Name</label>
|
|
||||||
<input type="text" id="filterName" name="filterName" class="form-control rounded-1" tabindex="1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="filterAlgorithm" class="form-label">Matching Algorithm</label>
|
|
||||||
<select name="filterAlgorithm" id="filterAlgorithm" class="select-2" data-dropdownparent="#filterFormModal" tabindex="2"></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="filterMatch" class="form-label">Matching Pattern</label>
|
|
||||||
<input type="text" id="filterMatch" name="filterMatch" class="form-control rounded-1" placeholder="" tabindex="3">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="form-switch ps-0">
|
|
||||||
<label for="filterWhitelist" class="form-check-label mb-2">Is Whitelist?</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" id="filterWhitelist" name="filterWhitelist" class="form-check-input ms-0 mt-0" tabindex="4">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="form-switch ps-0">
|
|
||||||
<label for="filterInsensitive" class="form-check-label mb-2">Case Insensitive?</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" id="filterInsensitive" name="filterInsensitive" class="form-check-input ms-0 mt-0" tabindex="5">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer px-4">
|
|
||||||
<button type="button" id="deleteEditFilter" class="btn btn-danger rounded-1 me-auto ms-0 form-edit" tabindex="6">Delete</button>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-1" tabindex="7">
|
|
||||||
<span class="form-create">Create</span>
|
|
||||||
<span class="form-edit">Confirm Edit</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary rounded-1 ms-3 me-0" data-bs-dismiss="modal" tabindex="8">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,34 +0,0 @@
|
|||||||
|
|
||||||
<div id="serverFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content rounded-1">
|
|
||||||
<form id="serverForm" class="mb-0" novalidate>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title ms-2">
|
|
||||||
Add Server
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<div class="d-flex flex-nowrap mb-3">
|
|
||||||
<div class="flex-fill">
|
|
||||||
<select name="serverOptions" id="serverOptions" class="select-2 rounded-1" data-dropdownparent="#serverFormModal">
|
|
||||||
<option value="">-- Select a Server --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="button" id="serverOptionsRefreshBtn" class="btn btn-secondary rounded-1 ms-3">
|
|
||||||
<i class="bi bi-arrow-clockwise d-block"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mb-0 form-text">
|
|
||||||
<b>Not seeing your server?</b>
|
|
||||||
Ensure that you are authenticated as either the owner or an administrator of the server you wish to add.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer px-4">
|
|
||||||
<button type="submit" class="btn btn-primary rounded-1 me-0">Submit</button>
|
|
||||||
<button type="button" class="btn btn-secondary rounded-1 ms-3" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,30 +0,0 @@
|
|||||||
<form id="serverSettingsForm" novalidate>
|
|
||||||
<div class="row my-3 px-3">
|
|
||||||
<div class="col-12 text-end">
|
|
||||||
<button type="submit" id="saveSettings" class="btn btn-primary rounded-1">Save Changes</button>
|
|
||||||
<button type="button" class="btn btn-outline-danger rounded-1 ms-3">Reset All</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row my-3 px-3">
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="colour-input"
|
|
||||||
data-id="defaultEmbedColour"
|
|
||||||
data-label="Default Embed Colour"
|
|
||||||
data-helptext="Default colour of each embed in Discord."
|
|
||||||
data-defaultcolour="#3498db">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-8"></div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="form-switch ps-0">
|
|
||||||
<label for="serverActive" class="form-check-label mb-2">Server Active?</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" name="serverActive" id="serverActive" class="form-check-input ms-0 mt-0">
|
|
||||||
<br>
|
|
||||||
<div class="form-text">Is this server active?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
@ -1,153 +0,0 @@
|
|||||||
<div id="subFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content rounded-1">
|
|
||||||
<form id="subForm" class="mb-0" novalidate>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title ms-2">
|
|
||||||
<span class="form-create">Add</span>
|
|
||||||
<span class="form-edit">Edit</span>
|
|
||||||
Subscription
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<input type="hidden" id="subId" name="subId" data-role="is-id">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subName" class="form-label">Name</label>
|
|
||||||
<input type="text" id="subName" name="subName" class="form-control rounded-1" placeholder="My News Feed" data-field="name" tabindex="1">
|
|
||||||
<div class="form-text">Use a unique name to refer to this subscription.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subUrl" class="form-label">URL</label>
|
|
||||||
<input type="url" id="subUrl" name="subUrl" class="form-control rounded-1" placeholder="http://example.com/rss.xml" data-field="url" tabindex="2">
|
|
||||||
<div class="form-text">Must point to a valid <a href="https://en.wikipedia.org/wiki/RSS" class="text-decoration-none" target="_blank">RSS</a> feed.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subChannels" class="form-label">Channels</label>
|
|
||||||
<select name="subChannels" id="subChannels" class="select-2" multiple data-dropdownparent="#subFormModal" tabindex="3"></select>
|
|
||||||
<div class="form-text">Subscription content will be sent to these channels.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subFilters" class="form-label">Filters</label>
|
|
||||||
<select name="subFilters" id="subFilters" class="select-2" multiple data-dropdownparent="#subFormModal" tabindex="4"></select>
|
|
||||||
<div class="form-text">Filters to apply to this subscription's content.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="mb-4 mb-lg-0">
|
|
||||||
<label for="subExtraNotes" class="form-label">Extra Notes</label>
|
|
||||||
<textarea id="subExtraNotes" name="subExtraNotes" class="form-control rounded-1" placeholder="" data-field="extra_notes" tabindex="5" style="resize: none; height: 7rem"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="form-switch mb-4 ps-0">
|
|
||||||
<label for="subActive" class="form-check-label mb-2">Active</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" id="subActive" name="subActive" class="form-check-input ms-0 mt-0" data-field="active" tabindex="6">
|
|
||||||
<br>
|
|
||||||
<div class="form-text">Inactive subscriptions wont be processed.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer px-4">
|
|
||||||
<!-- form-create -->
|
|
||||||
<button type="button" id="devGenerateSub" class="btn btn-outline-info rounded-1 me-3 ms-0 d-none" tabindex="7">(Dev) Generate</button>
|
|
||||||
<button type="button" id="deleteEditSub" class="btn btn-danger rounded-1 me-3 ms-0 form-edit" tabindex="8">Delete</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary rounded-1 me-auto ms-0" data-bs-toggle="modal" data-bs-target="#subAdvancedModal" tabindex="9">Advanced</button>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-1 me-0" tabindex="9">
|
|
||||||
<span class="form-create">Create</span>
|
|
||||||
<span class="form-edit">Confirm Edit</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="10">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="subAdvancedModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content rounded-1">
|
|
||||||
<form id="subAdvancedForm" class="mb-0" novalidate>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title ms-2">
|
|
||||||
<span class="form-create">Add</span>
|
|
||||||
<span class="form-edit">Edit</span>
|
|
||||||
Subscription · Advanced
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subTitleMutators" class="form-label">Title Mutators</label>
|
|
||||||
<select name="subTitleMutators" id="subTitleMutators" class="select-2 sub-mutators-field" multiple data-dropdownparent="#subAdvancedModal" tabindex="1"></select>
|
|
||||||
<div class="form-text">Apply mutators to article titles.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subDescMutators" class="form-label">Description Mutators</label>
|
|
||||||
<select name="subDescMutators" id="subDescMutators" class="select-2 sub-mutators-field" multiple data-dropdownparent="#subAdvancedModal" tabindex="2"></select>
|
|
||||||
<div class="form-text">Apply mutators to article descriptions.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4 d-none">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="" class="form-label">Article Fetch Limit</label>
|
|
||||||
<input type="number" id="subFetchLimit" class="form-control rounded-1" max="10" min="1" tabindex="3">
|
|
||||||
<div class="form-text">Limit the number of articles fetched every cycle.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4 d-none">
|
|
||||||
<div class="form-switch ps-0 mb-4">
|
|
||||||
<label for="subResetFetchLimit" class="form-check-label mb-2">Max Fetch Limit after the First Cycle</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" id="subResetFetchLimit" name="subResetFetchLimit" class="form-check-input ms-0 mt-0" tabindex="4">
|
|
||||||
<br>
|
|
||||||
<div class="form-text">Sets the Fetch Limit to 10 after the first cycle. Helps with initial spam.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="colour-input"
|
|
||||||
data-id="subEmbedColour"
|
|
||||||
data-label="Embed Colour"
|
|
||||||
data-helptext="Colour of each embed in Discord."
|
|
||||||
data-tabindex="5">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 ps-lg-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="subPubThreshold" class="form-label">Publish Datetime Threshold</label>
|
|
||||||
<input type="datetime-local" name="subPubThreshold" id="subPubThreshold" class="form-control" data-field="published_threshold" tabindex="9">
|
|
||||||
<div class="form-text">RSS content older than this datetime will be skipped.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 pe-lg-4">
|
|
||||||
<div class="form-switch ps-0">
|
|
||||||
<label for="subArticleFetchImage" class="form-check-label mb-2">Show Images on Embed?</label>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" id="subArticleFetchImage" name="subArticleFetchImage" class="form-check-input ms-0 mt-0" data-field="article_fetch_image" tabindex="10">
|
|
||||||
<br>
|
|
||||||
<div class="form-text">Show images on the discord embed?</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer px-4">
|
|
||||||
<button type="button" class="btn btn-primary rounded-1 me-0 ms-3" data-bs-toggle="modal" data-bs-target="#subFormModal" tabindex="11">Back</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,129 +0,0 @@
|
|||||||
{% extends 'layouts/base.html' %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{% endblock title %}
|
|
||||||
|
|
||||||
{% block stylesheets %}
|
|
||||||
<link type="text/css" rel="stylesheet" href="{% static '/css/home/index.css' %}">
|
|
||||||
<link type="text/css" rel="stylesheet" href="{% static '/css/select2.css' %}">
|
|
||||||
{% endblock stylesheets %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-lg px-0 h-100">
|
|
||||||
<div class="d-flex flex-nowrap h-100 border-start border-end">
|
|
||||||
<div class="d-flex flex-column bg-body-tertiary py-3 border-end" style="width: 4.5rem">
|
|
||||||
<ul id="serverList" class="nav nav-pills nav-flush flex-column mb-auto text-center">
|
|
||||||
<li class="nav-item">
|
|
||||||
<button type="button" id="newServerBtn" class="btn btn-outline-primary rounded-1 mt-1" style="width: 46px; height: 46px;">
|
|
||||||
<i class="bi bi-plus-lg fs-5"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow-1 container-fluid bg-body overflow-y-auto" style="min-width: 0;">
|
|
||||||
<div id="noSelectedServer" class="h-100">
|
|
||||||
<div class="d-flex justify-content-center align-items-center flex-column h-100">
|
|
||||||
<img src="{% static '/images/pyrss_logo.webp' %}" alt="PYRSS Logo">
|
|
||||||
<h1 class="fw-bold mb-4 font-atkinson-hyperlegible">PYRSS</h1>
|
|
||||||
<div class="d-flex align-items-center flex-nowrap flex-column">
|
|
||||||
<p class="col-lg-8 text-center">Select or <a onclick="newServerModal();" class="text-link text-decoration-none" role="button">add a server</a> from the left hand menu to get started. For more help check the <a href="https://gitea.corbz.dev/corbz/PYRSS-Website/src/branch/master/README.md" class="text-decoration-none" target="_blank">README</a>.</p>
|
|
||||||
<div class="col-lg-8 text-center">
|
|
||||||
<h5>Resources</h5>
|
|
||||||
<div class="hstack gap-3 justify-content-center">
|
|
||||||
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website" class="text-body text-decoration-none" target="_blank"><i class="bi bi-git fs-3"></i></a>
|
|
||||||
<a href="https://en.wikipedia.org/wiki/RSS" class="text-body text-decoration-none" target="_blank"><i class="bi bi-rss-fill fs-3"></i></a>
|
|
||||||
<a href="https://discord.com/developers/docs/intro" class="text-body text-decoration-none" target="_blank"><i class="bi bi-discord fs-3"></i></a>
|
|
||||||
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website/src/branch/master/README.md" class="text-body text-decoration-none" target="_blank"><i class="bi bi-question-circle-fill fs-3"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="selectedServerContainer" class="row" style="display: none;">
|
|
||||||
<div class="col-12 bg-body-tertiary border-bottom">
|
|
||||||
<div class="px-3 py-4 d-flex justify-content-start align-items-center">
|
|
||||||
<img alt="Selected Server Icon" class="rounded-3 selected-server-icon">
|
|
||||||
<div class="ms-3" style="min-width: 0">
|
|
||||||
<h3 class="mb-0 resolve-to-server-name text-truncate"></h3>
|
|
||||||
<h5 class="mb-0 resolve-to-server-id text-truncate text-body-secondary"></h5>
|
|
||||||
</div>
|
|
||||||
<div class="ms-auto">
|
|
||||||
<button type="button" id="serverSettingsBtn" class="btn btn-outline-secondary rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Server settings">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="deleteSelectedServerBtn" class="btn btn-outline-danger rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Close server">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="backToSelectServer" class="btn btn-outline-secondary rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Go back">
|
|
||||||
<i class="bi bi-box-arrow-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="serverJoinAlert" class="col-12 m-0">
|
|
||||||
<div class="px-3 mt-4 mx-2 alert alert-warning fade show rounded-1 d-flex align-items-center">
|
|
||||||
<div class="me-4">
|
|
||||||
<strong>Warning:</strong>
|
|
||||||
The Bot isn't a member of
|
|
||||||
<span class="resolve-to-server-name"></span>,
|
|
||||||
features here will not function properly, please add the bot before proceeding.
|
|
||||||
</div>
|
|
||||||
<a class="ms-auto btn btn-warning rounded-1 text-nowrap resolve-to-invite-link" target="_blank">Add PYRSS</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<ul id="serverTabs" class="nav py-3" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button id="subscriptionsTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#subscriptionsTabPane" type="button" aria-controls="subscriptionsTabPane" aria-selected="false">Subscriptions</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button id="filtersTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#filtersTabPane" type="button" aria-controls="filtersTabPane" aria-selected="false">Content Filters</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button id="contentTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#contentTabPane" type="button" aria-controls="contentTabPane" aria-selected="false">Tracked Content</button>
|
|
||||||
</li>
|
|
||||||
<!-- <li class="nav-item ms-auto" role="presentation">
|
|
||||||
<button id="settingsTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#settingsTabPane" type="button" aria-controls="settingsTabPane" aria-selected="false">Settings</button>
|
|
||||||
</li> -->
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div id="serverTabContent" class="tab-content">
|
|
||||||
<div id="subscriptionsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="subscriptionsTab" tabindex="0"></div>
|
|
||||||
<div id="filtersTabPane" class="tab-pane fade includes-table includes-table-controls includes-table-search" role="tabpanel" aria-labelledby="filtersTab" tabindex="0"> </div>
|
|
||||||
<div id="contentTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="contentTab" tabindex="0"></div>
|
|
||||||
<!-- <div id="settingsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="settingsTab" tabindex="0">{% include "home/includes/settingstab.html" %}</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include "home/includes/servermodal.html" %}
|
|
||||||
{% include "home/includes/submodal.html" %}
|
|
||||||
{% include "home/includes/filtermodal.html" %}
|
|
||||||
{% include "home/includes/deletemodal.html" %}
|
|
||||||
{% include "home/includes/settingsmodal.html" %}
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
||||||
{% block javascript %}
|
|
||||||
<script id="serverItemTemplate" type="text/template">
|
|
||||||
<li class="nav-item server-item" data-id="" data-bs-toggle="tooltip" data-bs-placement="right">
|
|
||||||
<button type="button" class="btn border-0 server-item-selector mb-2">
|
|
||||||
<img src="" alt="Guild Icon" class="rounded-circle" width="46" height="46">
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</script>
|
|
||||||
<script src="{% static 'js/api.js' %}"></script>
|
|
||||||
<script src="{% static 'js/table.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/index.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/servers.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/subscriptions.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/filters.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/content.js' %}"></script>
|
|
||||||
<script src="{% static 'js/home/settings.js' %}"></script>
|
|
||||||
{% endblock javascript %}
|
|
@ -1,19 +0,0 @@
|
|||||||
<div class="container-lg px-0">
|
|
||||||
<footer class="border border-bottom-0 bg-body-secondary px-4 py-3 d-flex flex-wrap justify-content-between align-items-center text-body-secondary">
|
|
||||||
<div class="col-md-4 d-flex align-items-center">
|
|
||||||
<span>© 2024 PYRSS</span>
|
|
||||||
</div>
|
|
||||||
<ul class="nav col-md-4 d-flex justify-content-end list-unstyled">
|
|
||||||
<li class="ms-3">
|
|
||||||
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website" class="text-reset" target="_blank">
|
|
||||||
<i class="bi bi-git fs-5"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="ms-3">
|
|
||||||
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website/wiki" class="text-reset" target="_blank">
|
|
||||||
<i class="bi bi-question-lg fs-5"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
@ -1,6 +1,5 @@
|
|||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
|
||||||
import environ
|
import environ
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -14,6 +13,9 @@ VERSION = "0.3.4"
|
|||||||
# BASE_DIR is the root of the project, all paths should be constructed from it using pathlib
|
# BASE_DIR is the root of the project, all paths should be constructed from it using pathlib
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# region Env Vars
|
||||||
|
|
||||||
# Create an environment and read variables from .env file
|
# Create an environment and read variables from .env file
|
||||||
env = environ.Env(DEBUG=(bool, True))
|
env = environ.Env(DEBUG=(bool, True))
|
||||||
environ.Env.read_env(BASE_DIR / ".env")
|
environ.Env.read_env(BASE_DIR / ".env")
|
||||||
@ -25,7 +27,10 @@ required_env_vars = (
|
|||||||
|
|
||||||
for var in required_env_vars:
|
for var in required_env_vars:
|
||||||
if not env(var, default=None):
|
if not env(var, default=None):
|
||||||
log.warn("Required environment variable %s is not set, the application will fail!", var)
|
log.warning("Required environment variable %s is not set, the application will fail!", var)
|
||||||
|
|
||||||
|
|
||||||
|
# region Security
|
||||||
|
|
||||||
# SECURITY WARNING: This is sensitive data, keep secure!
|
# SECURITY WARNING: This is sensitive data, keep secure!
|
||||||
SECRET_KEY = env('SECRET_KEY', default="unsecure-default-secret-key")
|
SECRET_KEY = env('SECRET_KEY', default="unsecure-default-secret-key")
|
||||||
@ -37,7 +42,8 @@ DEBUG = env('DEBUG')
|
|||||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "pyrss-website", env("HOST", default="127.0.0.1")]
|
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "pyrss-website", env("HOST", default="127.0.0.1")]
|
||||||
CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://pyrss-website", "https://" + env("HOST", default="127.0.0.1")]
|
CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://pyrss-website", "https://" + env("HOST", default="127.0.0.1")]
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
# region App definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
@ -49,6 +55,7 @@ INSTALLED_APPS = [
|
|||||||
'rest_framework',
|
'rest_framework',
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
|
"compressor",
|
||||||
'apps.api',
|
'apps.api',
|
||||||
'apps.home',
|
'apps.home',
|
||||||
'apps.authentication',
|
'apps.authentication',
|
||||||
@ -78,7 +85,7 @@ AUTH_USER_MODEL = "authentication.DiscordUser"
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
'DIRS': [BASE_DIR / "apps/templates"],
|
'DIRS': [BASE_DIR / "templates"],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'context_processors': [
|
||||||
@ -95,7 +102,7 @@ TEMPLATES = [
|
|||||||
WSGI_APPLICATION = 'core.wsgi.application'
|
WSGI_APPLICATION = 'core.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# region Database
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
||||||
|
|
||||||
DB_ENGINE = env("DB_ENGINE", default=None)
|
DB_ENGINE = env("DB_ENGINE", default=None)
|
||||||
@ -117,7 +124,7 @@ else:
|
|||||||
|
|
||||||
DATABASES = { "default": db_data }
|
DATABASES = { "default": db_data }
|
||||||
|
|
||||||
# Password validation
|
# region Passwd validation
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
@ -128,16 +135,15 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
# "django.contrib.auth.backends.ModelBackend",
|
|
||||||
"apps.authentication.backends.DiscordAuthenticationBackend"
|
"apps.authentication.backends.DiscordAuthenticationBackend"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Discord Related Settings
|
# region Discord Settings
|
||||||
|
|
||||||
BOT_TOKEN = env("BOT_TOKEN", default=None)
|
BOT_TOKEN = env("BOT_TOKEN", default=None)
|
||||||
DISCORD_KEY = env("DISCORD_KEY", default=None)
|
DISCORD_KEY = env("DISCORD_KEY", default=None)
|
||||||
DISCORD_SECRET = env("DISCORD_SECRET", default=None)
|
DISCORD_SECRET = env("DISCORD_SECRET", default=None)
|
||||||
DISCORD_SCOPES = env("DISCORD_SCOPES", default="identity,guilds").split(",") # ["identify", "guilds"]
|
DISCORD_SCOPES = env("DISCORD_SCOPES", default="identity,guilds").split(",") # ["identify", "guilds"]
|
||||||
DISCORD_CODE_EXCHANGE_REQUEST = {
|
DISCORD_CODE_EXCHANGE_REQUEST = {
|
||||||
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
"data": {
|
"data": {
|
||||||
@ -160,12 +166,11 @@ DISCORD_API_URL = env("DISCORD_API_URL", default="https://discord.com/api/v10")
|
|||||||
DISCORD_OAUTH2_URL = env("DISCORD_OAUTH2_URL", default=None)
|
DISCORD_OAUTH2_URL = env("DISCORD_OAUTH2_URL", default=None)
|
||||||
SUPERUSER_IDS = env("SUPERUSER_IDS", default="").split(",")
|
SUPERUSER_IDS = env("SUPERUSER_IDS", default="").split(",")
|
||||||
|
|
||||||
# Logging
|
# region Logging
|
||||||
# https://docs.djangoproject.com/en/5.0/topics/logging/
|
# https://docs.djangoproject.com/en/5.0/topics/logging/
|
||||||
|
|
||||||
LOGGING_DIR = BASE_DIR / "logs"
|
LOGGING_DIR = BASE_DIR / "logs"
|
||||||
LOGGING_DIR.mkdir(exist_ok=True)
|
LOGGING_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
'disable_existing_loggers': False,
|
'disable_existing_loggers': False,
|
||||||
@ -218,34 +223,43 @@ LOGGING = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# region Internationalization
|
||||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-gb'
|
LANGUAGE_CODE = 'en-gb'
|
||||||
|
|
||||||
TIME_ZONE = 'Europe/London'
|
TIME_ZONE = 'Europe/London'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# region Static files
|
||||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
|
|
||||||
# Extra places for collectstatic to find static files.
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = (
|
STATIC_URL = "/static/"
|
||||||
BASE_DIR / 'apps/static',
|
STATICFILES_DIRS = (BASE_DIR / "static",) # Extra places for collectstatic to find static files.
|
||||||
|
STATICFILES_FINDERS = (
|
||||||
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
|
"compressor.finders.CompressorFinder",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Media Files
|
# region SASS Compression
|
||||||
|
|
||||||
|
COMPRESS_ENABLED = True
|
||||||
|
COMPRESS_OFFLINE = not env("DEBUG")
|
||||||
|
COMPRESS_PRECOMPILERS = [("text/x-scss", "django_libsass.SassCompiler")]
|
||||||
|
COMPRESS_CSS_FILTERS = ["compressor.filters.css_default.CssAbsoluteIdentifier"]
|
||||||
|
LIBSASS_ADDITIONAL_INCLUDE_PATHS = [str(BASE_DIR / "static/bootstrap-5.3.3/scss")]
|
||||||
|
|
||||||
|
|
||||||
|
# region Media Files
|
||||||
|
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
|
||||||
# Django Rest Framework
|
# region Rest Framework
|
||||||
# https://www.django-rest-framework.org/
|
# https://www.django-rest-framework.org/
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
@ -257,5 +271,17 @@ REST_FRAMEWORK = {
|
|||||||
'anon': '100/day',
|
'anon': '100/day',
|
||||||
'user': '10000/hour'
|
'user': '10000/hour'
|
||||||
},
|
},
|
||||||
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
|
'apps.api.renderers.FixedJSONRenderer',
|
||||||
|
# 'rest_framework.renderers.AdminRenderer',
|
||||||
|
'rest_framework.renderers.BrowsableAPIRenderer'
|
||||||
|
],
|
||||||
"EXCEPTION_HANDLER": "apps.api.exceptions.conflict_exception_handler"
|
"EXCEPTION_HANDLER": "apps.api.exceptions.conflict_exception_handler"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# region Data logic
|
||||||
|
|
||||||
|
MAX_SUBSCRIPTIONS_PER_SERVER = env("MAX_SUBSCRIPTIONS_PER_SERVER", default=15)
|
||||||
|
MAX_FILTERS_PER_SERVER = env("MAX_FILTERS_PER_SERVER", default=15)
|
||||||
|
MAX_MESSAGE_STYLES_PER_SERVER = env("MAX_MESSAGE_STYLES_PER_SERVER", default=15)
|
||||||
|
@ -1,16 +1,26 @@
|
|||||||
|
anyio==4.6.0
|
||||||
asgiref==3.8.1
|
asgiref==3.8.1
|
||||||
bump2version==1.0.1
|
bump2version==1.0.1
|
||||||
certifi==2024.2.2
|
certifi==2024.2.2
|
||||||
charset-normalizer==3.3.2
|
charset-normalizer==3.3.2
|
||||||
Django==5.0.4
|
Django==5.0.4
|
||||||
|
django-appconf==1.0.6
|
||||||
|
django-compressor==4.5.1
|
||||||
django-environ==0.11.2
|
django-environ==0.11.2
|
||||||
django-filter==24.2
|
django-filter==24.2
|
||||||
|
django-libsass==0.9
|
||||||
djangorestframework==3.15.1
|
djangorestframework==3.15.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
|
h11==0.14.0
|
||||||
|
httpcore==1.0.5
|
||||||
|
httpx==0.27.2
|
||||||
idna==3.7
|
idna==3.7
|
||||||
|
libsass==0.23.0
|
||||||
packaging==24.1
|
packaging==24.1
|
||||||
psycopg2==2.9.9
|
psycopg2==2.9.9
|
||||||
|
rcssmin==1.1.2
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
rjsmin==1.2.2
|
||||||
setuptools==72.1.0
|
setuptools==72.1.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
sqlparse==0.5.0
|
sqlparse==0.5.0
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
python manage.py collectstatic
|
python manage.py collectstatic
|
||||||
|
python manage.py compress
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
12
static/bootstrap-5.3.3/.babelrc.js
vendored
Normal file
12
static/bootstrap-5.3.3/.babelrc.js
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'@babel/preset-env',
|
||||||
|
{
|
||||||
|
loose: true,
|
||||||
|
bugfixes: true,
|
||||||
|
modules: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
11
static/bootstrap-5.3.3/.browserslistrc
Normal file
11
static/bootstrap-5.3.3/.browserslistrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# https://github.com/browserslist/browserslist#readme
|
||||||
|
|
||||||
|
>= 0.5%
|
||||||
|
last 2 major versions
|
||||||
|
not dead
|
||||||
|
Chrome >= 60
|
||||||
|
Firefox >= 60
|
||||||
|
Firefox ESR
|
||||||
|
iOS >= 12
|
||||||
|
Safari >= 12
|
||||||
|
not Explorer <= 11
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user