Compare commits
142 Commits
master
...
models-rew
Author | SHA1 | Date | |
---|---|---|---|
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 |
220
CHANGELOG.md
220
CHANGELOG.md
@ -1,73 +1,191 @@
|
||||
# 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)
|
||||
- Enhancement: `data-field` implementation for subscription form
|
||||
- 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
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
**v0.3.3**
|
||||
## [Unreleased]
|
||||
|
||||
- Enhancement: Added some refreshing new fonts (sora & atkison hyperlegible)
|
||||
- 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
|
||||
### Added
|
||||
|
||||
**v0.3.2**
|
||||
- `UniqueContentRule` model, allows the user to determine how unique RSS items are defined
|
||||
- `unique_content_rules` attribute to the `Subscription` model, many-to-many relationship with the related model
|
||||
- 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
|
||||
|
||||
- Fix: invite link refered to wrong Discord application, because the client Id was hard coded
|
||||
- Enhancement: enabled pagination for the `/api/guild-settings/` endpoint
|
||||
### Fixed
|
||||
|
||||
**v0.3.1**
|
||||
- Footer links pointing to older domain
|
||||
- Tracked content ListView API incorrectly ordering by oldest first, instead of newest first
|
||||
|
||||
- Fix: issues brought from previous version
|
||||
### Changed
|
||||
|
||||
**breaking changes v.0.3.0**
|
||||
- Web interface now uses the full device width, rather than a smaller maximum width
|
||||
- Server sidebar use more width, also displays name and guild ID, becomes smaller on small devices
|
||||
- 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
|
||||
|
||||
- Enhancement: store guild settings in separate model
|
||||
- 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`
|
||||
### Removed
|
||||
|
||||
**v0.2.2**
|
||||
- Unused code for the legacy 'server settings tab' from before it became a modal
|
||||
|
||||
- Enhancement: added open graph meta tags
|
||||
- Fix: csrf trusted origins warning, from not including url protocol
|
||||
- Fix/Enhancement: Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
|
||||
## [0.3.4] - 2024-09-12
|
||||
|
||||
**v0.2.1**
|
||||
### Added
|
||||
|
||||
- Enhancement: Added confirmation modal for closing a server
|
||||
- Add wiki link button to footer
|
||||
|
||||
**v0.2.0**
|
||||
### Fixed
|
||||
|
||||
- Enhancement: Improved warning when server doesn't include bot member
|
||||
- Enhancement: Made it easier to update labels to the current server's details
|
||||
- Enhancement: 'Go Back' button on the server page, takes the user to the 'select a server' page.
|
||||
- Docs: Further documented `static/home/servers.js`
|
||||
- Other: removed some unused/unreferenced code
|
||||
- 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
|
||||
|
||||
**v0.1.14**
|
||||
### Changed
|
||||
|
||||
- Fix: layout issue for the 'select a server' page was off-centre
|
||||
- `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
|
||||
|
||||
**v0.1.13**
|
||||
## [0.3.3] - 2024-08-24
|
||||
|
||||
- Docs: Start of changelog
|
||||
- Fix: remove db flush from entrypoint file
|
||||
### Added
|
||||
|
||||
**v0.1.0**
|
||||
- Added font: sora
|
||||
- Added font: Atkinson Hyperlegible
|
||||
- added license file
|
||||
|
||||
- Initial Release
|
||||
### 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 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
|
||||
changes on behalf of the server they are representing.
|
||||
An object permission class, the object must have a 'server' attribute.
|
||||
"""
|
||||
|
||||
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'")
|
||||
|
||||
|
||||
# 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
|
||||
return ServerMember.objects.filter(user=request.user, server=obj.server).exists()
|
||||
|
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,25 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.conf import settings
|
||||
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,
|
||||
DiscordChannel,
|
||||
Subscription,
|
||||
Content,
|
||||
UniqueContentRule
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#region Dynamic Model
|
||||
# This DynamicModelSerializer is from a StackOverflow user in an obscure thread.
|
||||
# I wish that I could remember which thread, because god bless that man.
|
||||
|
||||
@ -109,108 +119,154 @@ class DynamicModelSerializer(serializers.ModelSerializer):
|
||||
abstract = True
|
||||
|
||||
|
||||
class SubChannelSerializer(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for SubChannel Model.
|
||||
"""
|
||||
|
||||
class ServerSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = SubChannel
|
||||
fields = ("id", "channel_id", "channel_name", "subscription")
|
||||
model = Server
|
||||
fields = ("id", "name", "icon_hash", "active")
|
||||
|
||||
|
||||
class FilterSerializer(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the Filter Model.
|
||||
"""
|
||||
|
||||
class ContentFilterSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = Filter
|
||||
fields = ("id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id")
|
||||
model = ContentFilter
|
||||
fields = (
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"is_whitelist"
|
||||
)
|
||||
|
||||
|
||||
class ArticleMutatorSerializer(DynamicModelSerializer):
|
||||
|
||||
class MessageMutatorSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = ArticleMutator
|
||||
model = MessageMutator
|
||||
fields = ("id", "name", "value")
|
||||
|
||||
|
||||
class SubscriptionSerializer_GET(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the Subscription Model.
|
||||
"""
|
||||
class MessageStyleSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = MessageStyle
|
||||
fields = (
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"is_embed",
|
||||
"is_hyperlinked",
|
||||
"show_author",
|
||||
"show_timestamp",
|
||||
"show_images",
|
||||
"fetch_images",
|
||||
"title_mutator",
|
||||
"description_mutator",
|
||||
"auto_created"
|
||||
)
|
||||
read_only_fields = ("auto_created",)
|
||||
|
||||
article_title_mutators = ArticleMutatorSerializer(many=True)
|
||||
article_desc_mutators = ArticleMutatorSerializer(many=True)
|
||||
active = serializers.BooleanField(initial=True)
|
||||
|
||||
class UniqueContentRuleSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = UniqueContentRule
|
||||
fields = ("id", "name", "value")
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
channels_detail = serializers.SerializerMethodField()
|
||||
# unique_rules = UniqueContentRuleSerializ er(many=True) # TODO: solve? causes issues with submission.
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = (
|
||||
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
|
||||
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"extra_notes",
|
||||
"active",
|
||||
"publish_threshold",
|
||||
"channels",
|
||||
"channels_detail",
|
||||
"filters",
|
||||
"message_style",
|
||||
"unique_rules"
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Serializer for the Subscription Model.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
class ContentSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = Subscription
|
||||
model = Content
|
||||
fields = (
|
||||
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
|
||||
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
|
||||
"id",
|
||||
"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 .views import (
|
||||
SubChannel_ListView,
|
||||
SubChannel_DetailView,
|
||||
Filter_ListView,
|
||||
Filter_DetailView,
|
||||
Server_ListView,
|
||||
Server_DetailView,
|
||||
ContentFilter_ListView,
|
||||
ContentFilter_DetailView,
|
||||
MessageMutator_ListView,
|
||||
MessageMutator_DetailView,
|
||||
MessageStyle_ListView,
|
||||
MessageStyle_DetailView,
|
||||
Subscription_ListView,
|
||||
Subscription_DetailView,
|
||||
Subscription_SubChannelView,
|
||||
SavedGuild_ListView,
|
||||
SavedGuild_DetailView,
|
||||
TrackedContent_ListView,
|
||||
TrackedContent_DetailView,
|
||||
ArticleMutator_ListView,
|
||||
ArticleMutator_DetailView,
|
||||
GuildSettings_ListView,
|
||||
GuildSettings_DetailView
|
||||
Content_ListView,
|
||||
Content_DetailView,
|
||||
UniqueContentRule_ListView,
|
||||
UniqueContentRule_DetailView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
|
||||
path("api-token-auth/", obtain_auth_token),
|
||||
|
||||
path("subchannel/", include([
|
||||
path("", SubChannel_ListView.as_view(), name="subchannel"),
|
||||
path("<str:pk>/", SubChannel_DetailView.as_view(), name="subchannel-detail")
|
||||
# region Servers
|
||||
path("servers/", include([
|
||||
path("", Server_ListView.as_view()),
|
||||
path("<int:pk>/", Server_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("filter/", include([
|
||||
path("", Filter_ListView.as_view(), name="filter"),
|
||||
path("<str:pk>/", Filter_DetailView.as_view(), name="filter-detail")
|
||||
# region Filters
|
||||
path("filters/", include([
|
||||
path("", ContentFilter_ListView.as_view()),
|
||||
path("<int:pk>/", ContentFilter_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("subscription/", include([
|
||||
path("", Subscription_ListView.as_view(), name="subscription"),
|
||||
path("<str:pk>/", include([
|
||||
path("", Subscription_DetailView.as_view(), name="subscription-detail"),
|
||||
path("subchannels/", Subscription_SubChannelView.as_view(), name="subscription-channels")
|
||||
]))
|
||||
# region Message Mutators
|
||||
path("message-mutators/", include([
|
||||
path("", MessageMutator_ListView.as_view()),
|
||||
path("<int:pk>/", MessageMutator_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("saved-guilds/", include([
|
||||
path("", SavedGuild_ListView.as_view(), name="saved-guilds"),
|
||||
path("<int:pk>/", SavedGuild_DetailView.as_view(), name="saved-guilds-detail")
|
||||
# region Message Styles
|
||||
path("message-styles/", include([
|
||||
path("", MessageStyle_ListView.as_view()),
|
||||
path("<int:pk>/", MessageStyle_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("guild-settings/", include([
|
||||
path("", GuildSettings_ListView.as_view(), name="guild-settings"),
|
||||
path("<int:pk>/", GuildSettings_DetailView.as_view(), name="guild-settings-detail")
|
||||
# region Subscriptions
|
||||
path("subscriptions/", include([
|
||||
path("", Subscription_ListView.as_view()),
|
||||
path("<int:pk>/", Subscription_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("tracked-content/", include([
|
||||
path("", TrackedContent_ListView.as_view(), name="tracked-content"),
|
||||
path("<path:pk>/", TrackedContent_DetailView.as_view(), name="tracked-content-detail")
|
||||
# region Content
|
||||
path("content/", include([
|
||||
path("", Content_ListView.as_view()),
|
||||
path("<int:pk>/", Content_DetailView.as_view())
|
||||
])),
|
||||
|
||||
path("article-mutator/", include([
|
||||
path("", ArticleMutator_ListView.as_view(), name="article-mutator"),
|
||||
path("<int:pk>/", ArticleMutator_DetailView.as_view(), name="article-mutator-detail")
|
||||
])),
|
||||
# region Unique Rules
|
||||
path("unique-content-rules/", include([
|
||||
path("", UniqueContentRule_ListView.as_view()),
|
||||
path("<int:pk>/", UniqueContentRule_DetailView.as_view())
|
||||
]))
|
||||
]
|
||||
|
@ -2,29 +2,34 @@
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.models import Subquery
|
||||
from django.db.utils import IntegrityError
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import status, permissions, filters, generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import permissions, filters, generics
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
|
||||
from apps.authentication.models import DiscordUser
|
||||
from apps.home.models import (
|
||||
Server,
|
||||
ContentFilter,
|
||||
MessageMutator,
|
||||
MessageStyle,
|
||||
Subscription,
|
||||
Content,
|
||||
UniqueContentRule
|
||||
)
|
||||
from apps.authentication.models import DiscordUser, ServerMember
|
||||
from .metadata import ExpandedMetadata
|
||||
from .serializers import (
|
||||
SubChannelSerializer,
|
||||
FilterSerializer,
|
||||
SubscriptionSerializer_GET,
|
||||
SubscriptionSerializer_POST,
|
||||
SavedGuildSerializer,
|
||||
TrackedContentSerializer_GET,
|
||||
TrackedContentSerializer_POST,
|
||||
ArticleMutatorSerializer,
|
||||
GuildSettingsSerializer
|
||||
ServerSerializer,
|
||||
ContentFilterSerializer,
|
||||
MessageMutatorSerializer,
|
||||
MessageStyleSerializer,
|
||||
SubscriptionSerializer,
|
||||
ContentSerializer,
|
||||
UniqueContentRuleSerializer
|
||||
)
|
||||
from .permissions import HasServerAccess
|
||||
from .errors import NotAMemberError
|
||||
|
||||
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
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# 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
|
||||
"""
|
||||
|
||||
class ListView(generics.ListAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
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
|
||||
metadata_class = ExpandedMetadata
|
||||
|
||||
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):
|
||||
"""
|
||||
View to provide details on a particular Subscription model instances.
|
||||
|
||||
Supports: GET, PUT, PATCH, DELETE
|
||||
"""
|
||||
|
||||
class ListCreateView(generics.ListCreateAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
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
|
||||
metadata_class = ExpandedMetadata
|
||||
queryset = TrackedContent.objects.all().order_by("-creation_datetime")
|
||||
|
||||
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):
|
||||
"""
|
||||
View to provide details on a particular TrackedContent model instances.
|
||||
|
||||
Supports: GET, PUT, PATCH, DELETE
|
||||
"""
|
||||
|
||||
class DetailView(generics.RetrieveAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
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]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
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 .models import DiscordUser
|
||||
from .models import DiscordUser, ServerMember
|
||||
|
||||
|
||||
@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_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
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -10,6 +12,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('home', '0001_initial'),
|
||||
]
|
||||
|
||||
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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
@ -37,4 +42,14 @@ class Migration(migrations.Migration):
|
||||
'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.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 %}
|
||||
|
||||
{% block title %} - Login {% endblock title %}
|
||||
@ -7,8 +7,8 @@
|
||||
{% block stylesheets %}{% endblock stylesheets %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-lg px-0 h-100">
|
||||
<div class="d-flex flex-nowrap h-100 border-start border-end position-relative">
|
||||
<div class="px-0 h-100">
|
||||
<div class="d-flex flex-nowrap h-100 position-relative">
|
||||
|
||||
<div class="modal d-block position-absolute">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
@ -3,17 +3,12 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth.views import LogoutView
|
||||
|
||||
from .views import DiscordLoginAction, DiscordLoginRedirect, Login, GuildsView, GuildChannelsView, SaveGuildView
|
||||
from .views import DiscordLoginAction, DiscordLoginRedirect, Login
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", Login.as_view(), name="login"),
|
||||
path("oauth2/login/", DiscordLoginAction.as_view(), name="discord-login"),
|
||||
path("oauth2/login/redirect/", DiscordLoginRedirect.as_view(), name="discord-login-redirect"),
|
||||
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")
|
||||
|
||||
path("logout/", LogoutView.as_view(), name="logout")
|
||||
]
|
||||
|
@ -49,7 +49,8 @@ class DiscordLoginRedirect(View):
|
||||
discord_user = authenticate(request, discord_user_data=raw_user_data)
|
||||
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:
|
||||
"""
|
||||
@ -118,34 +119,6 @@ class Login(TemplateView):
|
||||
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):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -157,9 +130,4 @@ class GuildChannelsView(View):
|
||||
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
||||
)
|
||||
|
||||
return JsonResponse(response.json(), safe=False)
|
||||
|
||||
|
||||
class SaveGuildView(View):
|
||||
|
||||
pass
|
||||
return JsonResponse(response.json(), status=response.status_code, safe=False)
|
||||
|
@ -2,52 +2,105 @@
|
||||
|
||||
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", "active"]
|
||||
|
||||
|
||||
@admin.register(ContentFilter)
|
||||
class ContentFilterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"is_whitelist"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(MessageMutator)
|
||||
class MessageMutatorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"value"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(MessageStyle)
|
||||
class MessageStyleAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"server",
|
||||
"is_embed",
|
||||
"is_hyperlinked",
|
||||
"show_author",
|
||||
"show_timestamp",
|
||||
"show_images",
|
||||
"fetch_images",
|
||||
"title_mutator",
|
||||
"description_mutator",
|
||||
"auto_created"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(DiscordChannel)
|
||||
class DiscordChannelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"is_nsfw"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Subscription)
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
class Subscription(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "name", "url", "guild_id",
|
||||
"creation_datetime", "active"
|
||||
]
|
||||
|
||||
@admin.register(SubChannel)
|
||||
class SubChannelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "channel_id", "subscription"
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"extra_notes",
|
||||
"active",
|
||||
"message_style"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Filter)
|
||||
class FilterAdmin(admin.ModelAdmin):
|
||||
@admin.register(Content)
|
||||
class ContentAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "name", "guild_id"
|
||||
"id",
|
||||
"subscription",
|
||||
"item_id",
|
||||
"item_guid",
|
||||
"item_url",
|
||||
"item_title",
|
||||
"item_content_hash"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(TrackedContent)
|
||||
class TrackedContentAdmin(admin.ModelAdmin):
|
||||
@admin.register(UniqueContentRule)
|
||||
class UniqueContentRule(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"
|
||||
"id",
|
||||
"name",
|
||||
"value"
|
||||
]
|
||||
|
@ -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.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -11,53 +10,70 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=32)),
|
||||
('keywords', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('regex', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('whitelist', models.BooleanField(default=False)),
|
||||
('guild_id', models.CharField(max_length=128)),
|
||||
('match', models.CharField(max_length=256)),
|
||||
('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')])),
|
||||
('is_insensitive', models.BooleanField()),
|
||||
('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(
|
||||
name='SavedGuilds',
|
||||
name='BotLogicLogs',
|
||||
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')),
|
||||
('name', models.CharField(help_text='Name of the represented guild.', max_length=128)),
|
||||
('icon', models.CharField(help_text="Hash for the represented guild's icon.", max_length=128)),
|
||||
('permissions', models.CharField(help_text='Guild permissions for the user who added this instance.', max_length=64)),
|
||||
('owner', models.BooleanField(default=False, help_text="Does the 'added by' user own this guild?")),
|
||||
('level', models.CharField(max_length=32)),
|
||||
('message', models.CharField(max_length=256)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
|
||||
],
|
||||
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(
|
||||
name='Subscription',
|
||||
@ -65,76 +81,26 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=32)),
|
||||
('url', models.URLField()),
|
||||
('guild_id', models.CharField(max_length=128)),
|
||||
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
('extra_notes', models.CharField(blank=True, max_length=250, null=True)),
|
||||
('uwuify', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('extra_notes', models.CharField(blank=True, default='', max_length=250)),
|
||||
('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(
|
||||
name='TrackedContent',
|
||||
name='Content',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('guid', models.CharField(max_length=128)),
|
||||
('title', models.CharField(max_length=128)),
|
||||
('url', models.URLField(unique=True)),
|
||||
('blocked', models.BooleanField(default=False)),
|
||||
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
('guild_id', models.CharField(max_length=128)),
|
||||
('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)),
|
||||
('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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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,54 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
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__)
|
||||
|
||||
|
||||
class GuildSettings(models.Model):
|
||||
# region Server
|
||||
|
||||
class Server(models.Model):
|
||||
"""
|
||||
Represents settings for a saved Discord Guild `SavedGuild`.
|
||||
These objects aren't linked through foreignkey because
|
||||
`SavedGuild` is user user unique, not Discord Guild unique.
|
||||
Represents a Discord Server.
|
||||
Instances of this model are automatically handled, and manual intervension
|
||||
should be avoided if possible.
|
||||
"""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
|
||||
guild_id = models.CharField(
|
||||
verbose_name=_("guild id"),
|
||||
max_length=128,
|
||||
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")
|
||||
)
|
||||
id = models.PositiveIntegerField(primary_key=True)
|
||||
name = models.CharField(max_length=128)
|
||||
icon_hash = models.CharField(max_length=128, blank=True, null=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Metadata for the GuildSettings model.
|
||||
"""
|
||||
|
||||
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
|
||||
verbose_name = "server"
|
||||
verbose_name_plural = "servers"
|
||||
get_latest_by = "name"
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
return GuildSettings.objects.get(guild_id=self.guild_id)
|
||||
def icon_url(self):
|
||||
return f"https://cdn.discordapp.com/icons/{self.id}/{self.icon_hash}.webp?size=80"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
GuildSettings.objects.get_or_create(guild_id=self.guild_id)
|
||||
super().save(*args, **kwargs)
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class SubChannel(models.Model):
|
||||
# region Content Filter
|
||||
|
||||
class ContentFilter(models.Model):
|
||||
"""
|
||||
Represents a Discord TextChannel, saved against a Subscription.
|
||||
SubChannels are used as targets to send content from Subscriptions.
|
||||
Filters for the content produced by Subscriptions.
|
||||
Owned by the related server.
|
||||
"""
|
||||
|
||||
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_ANY = 1
|
||||
MATCH_ALL = 2
|
||||
@ -189,196 +64,234 @@ class Filter(models.Model):
|
||||
(MATCH_LITERAL, _("Exact: Item contains this string")),
|
||||
(MATCH_REGEX, _("Regular expression: Item matches this regex")),
|
||||
(MATCH_FUZZY, _("Fuzzy: Item contains a word similar to this word")),
|
||||
# (MATCH_AUTO, _("Automatic")),
|
||||
)
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
|
||||
name = models.CharField(_("name"), max_length=128)
|
||||
|
||||
match = models.CharField(_("match"), max_length=256, blank=True)
|
||||
|
||||
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)
|
||||
name = models.CharField(max_length=32)
|
||||
match = models.CharField(max_length=256, blank=False)
|
||||
matching_algorithm = models.PositiveIntegerField(choices=MATCHING_ALGORITHMS)
|
||||
is_insensitive = models.BooleanField()
|
||||
is_whitelist = models.BooleanField()
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "guild_id"],
|
||||
name="unique name & guild id pair"
|
||||
)
|
||||
]
|
||||
verbose_name = "filter"
|
||||
verbose_name_plural = "filters"
|
||||
get_latest_by = "id"
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TrackedContent(models.Model):
|
||||
# region Message Mutator
|
||||
|
||||
class MessageMutator(models.Model):
|
||||
"""
|
||||
Tracked Content Model
|
||||
'Tracked Content' identifies articles and tracks them being sent.
|
||||
This is used to ensure duplicate articles aren't sent in feeds.
|
||||
Mutators to be applied via the Bot.
|
||||
Instances of this model are predefined via migrations.
|
||||
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)
|
||||
name = models.CharField(max_length=64)
|
||||
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
|
||||
|
||||
|
||||
# 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'")
|
||||
|
||||
super().__init__(self)
|
||||
|
||||
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.PositiveIntegerField(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=True)
|
||||
filters = models.ManyToManyField(to=ContentFilter, blank=True)
|
||||
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
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}"
|
||||
|
126
apps/home/static/home/css/index.css
Normal file
126
apps/home/static/home/css/index.css
Normal file
@ -0,0 +1,126 @@
|
||||
@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-sidebar {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.server-sidebar {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.server-item > button {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.server-item > button > div {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.server-item .server-item-labels {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.server-sidebar > ul > li:has(> #newServerBtn) {
|
||||
display: flex;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
#newServerBtn {
|
||||
width: 45px !important;
|
||||
height: 45px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.server-item {
|
||||
max-width: calc(16rem - 1rem);
|
||||
}
|
||||
|
||||
.server-item-selector {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
color: var(--bs-text-body);
|
||||
background-color: var(--bg-tertiary-bg);
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.server-item-selector .font-monospace {
|
||||
color: var(--bs-secondary-color)
|
||||
}
|
||||
|
||||
.server-item-selector:hover,
|
||||
.server-item-selector:focus,
|
||||
.server-item-selector:active,
|
||||
.active > .server-item-selector {
|
||||
background-color: var(--bs-tertiary-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
/* 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:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
/* Yellow Dot Indicator for the server sidebar */
|
||||
.dot-container {
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.dot-container {
|
||||
transform: translate(25%, -25%);
|
||||
}
|
||||
}
|
||||
|
||||
.dot-icon {
|
||||
height: .75rem;
|
||||
width: .75rem;
|
||||
background-color: var(--bs-primary);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
118
apps/home/static/home/css/tables.css
Normal file
118
apps/home/static/home/css/tables.css
Normal file
@ -0,0 +1,118 @@
|
||||
|
||||
.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);
|
||||
}
|
@ -1,18 +1,24 @@
|
||||
$(document).ready(async function() {
|
||||
await initSubscriptionTable();
|
||||
await initFiltersTable();
|
||||
await initContentTable();
|
||||
// await initSubscriptionTable();
|
||||
// await initFiltersTable();
|
||||
// await initContentTable();
|
||||
|
||||
initSubscriptionsModule();
|
||||
initFiltersModule();
|
||||
initContentModule();
|
||||
initMessageStylesModule();
|
||||
|
||||
await loadServers();
|
||||
$("#subscriptionsTab").click();
|
||||
|
||||
await loadSavedGuilds();
|
||||
await loadServerOptions();
|
||||
});
|
||||
|
||||
$(document).on("selectedServerChange", function() {
|
||||
$("#subscriptionsTab").click();
|
||||
});
|
||||
|
||||
|
||||
// region Hex Strings
|
||||
|
||||
function genHexString(len=6) {
|
||||
let output = '';
|
||||
for (let i = 0; i < len; ++i) {
|
||||
@ -21,6 +27,14 @@ function genHexString(len=6) {
|
||||
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
|
||||
function formatStringDate(date, format) {
|
||||
const padZero = (num, len) => String(num).padStart(len, "0");
|
||||
@ -76,7 +90,8 @@ function formatStringDate(date, format) {
|
||||
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() {
|
||||
$(this).closest(".colour-control-group").find(".colour-control-text").val($(this).val());
|
||||
});
|
||||
@ -122,12 +137,12 @@ $(document).ready(function() {
|
||||
$(this).replaceWith(`
|
||||
<label for="${id}Picker" class="form-label">${label}</label>
|
||||
<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}">
|
||||
<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}">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -155,14 +170,43 @@ $(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 OK modal
|
||||
|
||||
function validateBootstrapStyle(style) {
|
||||
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
|
||||
throw new Error(`${style} is not a valid style`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function okModal(title, bodyText, style, iconClass, func) {
|
||||
let $modal = $("#okModal");
|
||||
|
||||
validateBootstrapStyle(style);
|
||||
$modal.find(".modal-dismiss-btn").addClass(`btn-${style}`);
|
||||
$modal.find(".modal-dismiss-btn > i").addClass(iconClass);
|
||||
|
||||
$modal.find(".modal-title").text(title);
|
||||
$modal.find(".modal-body > p").html(bodyText);
|
||||
|
||||
$modal.find(".modal-dismiss-btn").off("click").on("click", async function(e) {
|
||||
if (func) await func();
|
||||
$modal.modal("hide");
|
||||
});
|
||||
|
||||
$modal.modal("show");
|
||||
}
|
||||
|
||||
|
||||
// region Confirm Modal
|
||||
|
||||
async function confirmationModal(title, bodyText, style, iconClass, acceptFunc, declineFunc) {
|
||||
let $modal = $("#confirmModal");
|
||||
|
||||
validateBootstrapStyle(style);
|
||||
$modal.find(".modal-confirm-btn").addClass(`btn-${style}`);
|
||||
$modal.find(".modal-confirm-btn > i").addClass(iconClass);
|
||||
|
||||
$modal.find(".modal-title").text(title);
|
||||
$modal.find(".modal-body > p").html(bodyText);
|
||||
@ -189,4 +233,28 @@ function arrayToHtmlList(array, bold=false) {
|
||||
});
|
||||
|
||||
return $ul;
|
||||
}
|
||||
|
||||
|
||||
// region Log Error
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
200
apps/home/static/home/js/servers.js
Normal file
200
apps/home/static/home/js/servers.js
Normal file
@ -0,0 +1,200 @@
|
||||
|
||||
// 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 = {};
|
||||
function loadedChannels(serverId) {
|
||||
if (!(serverId in _loadedChannels)) {
|
||||
throw new Error(`channels not loaded for server: ${serverId}`);
|
||||
}
|
||||
|
||||
return _loadedChannels[serverId]
|
||||
}
|
||||
|
||||
$(document).on("selectedServerChange", async function() {
|
||||
serverId = selectedServer.id; // take note incase 'selectedServer' changes
|
||||
|
||||
ajaxRequest(`/generate-channels?guild=${serverId}`, "GET")
|
||||
.then(channels => {
|
||||
_loadedChannels[serverId] = channels;
|
||||
})
|
||||
.catch(error => {
|
||||
logError(error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region UI Buttons
|
||||
|
||||
function createSelectButton(serverData) {
|
||||
// server details
|
||||
let id = serverData["id"];
|
||||
let name = serverData["name"];
|
||||
let iconHash = serverData["icon"];
|
||||
|
||||
let template = $($("#serverItemTemplate").html());
|
||||
let imageUrl = `https://cdn.discordapp.com/icons/${id}/${iconHash}.webp?size=80`;
|
||||
let altText = name.split(' ').map(word => word.charAt(0)).join(''); // initials of server name, used if iconUrl is 404
|
||||
|
||||
template.find("img").attr("src", imageUrl).attr("alt", altText);
|
||||
template.find(".js-guildName").text(name);
|
||||
template.find(".js-guildId").text(id);
|
||||
template.attr("data-id", id);
|
||||
|
||||
// Bind the button for selecting this server
|
||||
template.find(".server-item-selector").off("click").on("click", function() {
|
||||
selectServer(id);
|
||||
});
|
||||
|
||||
$("#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;
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #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 .server-item").removeClass("active");
|
||||
$(`#serverList .server-item[data-id=${id}]`).addClass("active");
|
||||
|
||||
// Global variable
|
||||
selectedServer = server;
|
||||
|
||||
// Update UI
|
||||
$("#noSelectedServer").hide();
|
||||
$("#selectedServerContainer").show().css("display", "flex");
|
||||
|
||||
// Announce change to any listeners
|
||||
$(document).trigger("selectedServerChange");
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Resolve Strings
|
||||
|
||||
function resolveServerStrings() {
|
||||
// Server icon
|
||||
$(".resolve-to-server-icon").attr(
|
||||
"src",
|
||||
`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.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() {
|
||||
|
||||
$(".server-loading-item").show();
|
||||
generateServers()
|
||||
.then(servers => {
|
||||
servers.forEach(server => addToLoadedServers(server, false));
|
||||
})
|
||||
.catch(error => {
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
window.location.href = "/login"; // discord token has expired
|
||||
break;
|
||||
case 429:
|
||||
$(".server-rate-limit").show();
|
||||
break;
|
||||
default:
|
||||
logError(error);
|
||||
break;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
$(".server-loading-item").hide();
|
||||
});
|
||||
}
|
||||
|
||||
// #endregion
|
478
apps/home/static/home/js/tables.js
Normal file
478
apps/home/static/home/js/tables.js
Normal file
@ -0,0 +1,478 @@
|
||||
|
||||
// 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: "text-center col-switch-width",
|
||||
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;
|
||||
}
|
||||
|
||||
$(document).on("selectedServerChange", function() {
|
||||
_tableFilters = {};
|
||||
_tableOrdering = {};
|
||||
});
|
||||
|
||||
|
||||
// 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);
|
||||
|
||||
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 {
|
||||
$(this).val(defaultVal).change();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
$(this).val(value).change();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onModalSubmit($modal, $table, url) {
|
||||
if (!selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
if (type === "checkbox") {
|
||||
value = $(this).prop("checked");
|
||||
}
|
||||
else {
|
||||
value = $(this).val();
|
||||
}
|
||||
|
||||
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 => {
|
||||
$modal.modal("hide");
|
||||
$table.trigger("doDataLoad");
|
||||
})
|
||||
.catch(error => logError(error));
|
||||
}
|
||||
|
||||
|
||||
// region Table Column Types
|
||||
|
||||
function renderEditColumn(data) {
|
||||
const name = sanitise(data);
|
||||
return `<button type="button" class="btn btn-link text-start text-decoration-none edit-modal">${name}</button>`;
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
|
||||
// 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");
|
||||
}
|
111
apps/home/static/home/js/tabs/filters.js
Normal file
111
apps/home/static/home/js/tabs/filters.js
Normal file
@ -0,0 +1,111 @@
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
// 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 : ""
|
||||
}
|
||||
));
|
||||
});
|
||||
}
|
235
apps/home/static/home/js/tabs/styles.js
Normal file
235
apps/home/static/home/js/tabs/styles.js
Normal file
@ -0,0 +1,235 @@
|
||||
const styleTableId = "#styleTable";
|
||||
const styleModalId = "#styleFormModal";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initMessageStylesModule() {
|
||||
initializeDataTable(
|
||||
styleTableId,
|
||||
[
|
||||
{
|
||||
title: "Name",
|
||||
data: "name",
|
||||
render: function(data, type, row) {
|
||||
const btn = renderEditColumn(data);
|
||||
return row.auto_created ?
|
||||
$(btn).removeClass("edit-modal").addClass("disabled")[0]
|
||||
: btn;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Is Embed",
|
||||
data: "is_embed",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Is Hyperlinked",
|
||||
data: "is_hyperlinked",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Show Author",
|
||||
data: "show_author",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Show Timestamp",
|
||||
data: "show_timestamp",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Show Images",
|
||||
data: "show_images",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Fetch Images",
|
||||
data: "fetch_images",
|
||||
className: "text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Title Mutator",
|
||||
data: "title_mutator"
|
||||
},
|
||||
{
|
||||
title: "Description Mutator",
|
||||
data: "description_mutator"
|
||||
},
|
||||
{
|
||||
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);
|
||||
|
||||
await confirmationModal(
|
||||
`Delete a Message Style`,
|
||||
`Do you wish to permanently delete <b>${name}</b>?`,
|
||||
"danger",
|
||||
"bi-trash3",
|
||||
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);
|
||||
},
|
||||
() => { $(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;
|
||||
}
|
||||
|
||||
await okModal(
|
||||
"Cannot Delete Style",
|
||||
`<b>${row.name}</b> can't be deleted, as it was created by the system, and thus is immutable.`,
|
||||
"warning",
|
||||
"bi-arrow-return-right",
|
||||
null,
|
||||
);
|
||||
return
|
||||
}
|
||||
|
||||
const names = rows.map(row => row.name);
|
||||
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
|
||||
|
||||
await confirmationModal(
|
||||
`Delete ${isMany ? "Many Message Styles" : "a Message Style"}`,
|
||||
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> message style${isMany ? "s" : ""}?<br><br>${namesString}`,
|
||||
"danger",
|
||||
"bi-trash3",
|
||||
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);
|
||||
},
|
||||
null
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
// 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 () => {
|
||||
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);
|
||||
}
|
254
apps/home/static/home/js/tabs/subs.js
Normal file
254
apps/home/static/home/js/tabs/subs.js
Normal file
@ -0,0 +1,254 @@
|
||||
|
||||
const subTableId = "#subTable";
|
||||
const subModalId = "#subFormModal";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initSubscriptionsModule() {
|
||||
initializeDataTable(
|
||||
subTableId,
|
||||
[
|
||||
{
|
||||
title: "Name",
|
||||
data: "name",
|
||||
className: "text-truncate col-3",
|
||||
render: renderEditColumn
|
||||
},
|
||||
{
|
||||
title: "URL",
|
||||
data: "url",
|
||||
className: "text-truncate col-4",
|
||||
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_detail",
|
||||
className: "text-center",
|
||||
render: data => renderArrayBadgesColumn(data.map(item => "#" + item.name))
|
||||
},
|
||||
{
|
||||
title: "Rules",
|
||||
data: "unique_rules",
|
||||
className: "text-center text-nowrap col-1",
|
||||
render: function(data, type) {
|
||||
let badges = $("<div>");
|
||||
data.forEach(function(rule, idx) {
|
||||
let badge = $(`<span class="badge text-bg-secondary">${rule}</span>`) // TODO: translate to rule.name
|
||||
if (idx > 0) { badge.addClass("ms-2") }
|
||||
badges.append(badge);
|
||||
});
|
||||
|
||||
return badges.html();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
data: "created_at",
|
||||
className: "col-3",
|
||||
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",
|
||||
orderable: false,
|
||||
className: "text-center form-switch col-1",
|
||||
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);
|
||||
|
||||
await confirmationModal(
|
||||
`Delete a Subscription`,
|
||||
`Do you wish to permanently delete <b>${name}</b>?`,
|
||||
"danger",
|
||||
async () => {
|
||||
await ajaxRequest(`/api/subscriptions/${subscription.id}/`, "DELETE");
|
||||
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||
},
|
||||
() => { $(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 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 ajaxRequest(`/api/subscriptions/${row.id}/`, "DELETE");
|
||||
});
|
||||
|
||||
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||
},
|
||||
null
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
// 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.
|
||||
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 = loadedChannels(selectedServer.id);
|
||||
data.forEach(item => {
|
||||
$input.append($(
|
||||
"<option>",
|
||||
{text: `#${item.name}`, value: item.id}
|
||||
));
|
||||
})
|
||||
|
||||
$input.prop("disabled", false);
|
||||
}
|
209
apps/home/templates/home/index.html
Normal file
209
apps/home/templates/home/index.html
Normal file
@ -0,0 +1,209 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% endblock title %}
|
||||
|
||||
{% block stylesheets %}
|
||||
<link type="text/css" rel="stylesheet" href="{% static '/home/css/index.css' %}">
|
||||
<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 bg-body-secondary">
|
||||
<div class="d-flex flex-nowrap h-100">
|
||||
<div class="server-sidebar d-flex flex-column bg-body-secondary py-3">
|
||||
<h6 class="d-none d-lg-block ms-3">Discord Servers</h6>
|
||||
<ul id="serverList" class="nav nav-pills nav-flush flex-column mb-auto text-center px-lg-2 px-1 flex-nowrap overflow-y-auto">
|
||||
{% for i in "0123456789"|make_list %}
|
||||
<li class="nav-item server-loading-item">
|
||||
<div class="btn border-0 w-100 position-relative">
|
||||
<div class="d-flex justify-content-start align-items-center w-100 placeholder-wave">
|
||||
<div class="rounded-1 placeholder" style="min-width: 45px !important; min-height: 45px !important;"></div>
|
||||
<div class="text-start ps-2 w-100 flex-shrink-1 placeholder-wave">
|
||||
{% if forloop.counter0|divisibleby:2 %}
|
||||
<div class="placeholder rounded-1 w-75"></div>
|
||||
<div class="placeholder rounded-1 w-100"></div>
|
||||
{% else %}
|
||||
<div class="placeholder rounded-1 w-75"></div>
|
||||
<div class="placeholder rounded-1 w-50"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="nav-item server-rate-limit" style="display: none;">
|
||||
<div class="alert alert-warning text-start" role="alert">
|
||||
<p class="small">You are being rate limited.</p>
|
||||
<p class="small mb-0">Refresh the page to try again.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="d-flex flex-lg-row flex-column list-unstyled text-center flex-nowrap mb-0 px-lg-4 px-2 align-items-center text-body-secondary">
|
||||
<li>
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="text-reset" target="_blank">
|
||||
<i class="bi bi-git fs-6"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="ms-lg-4 ms-0">
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/wiki" class="text-reset" target="_blank">
|
||||
<i class="bi bi-question-lg fs-6"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mx-auto d-none d-lg-inline">
|
||||
<div class="vr"></div>
|
||||
</li>
|
||||
<li class="d-none d-lg-inline text-nowrap text-reset small">
|
||||
© 2024 PYRSS
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-grow-1 container-fluid bg-body rounded-top-2 rounded-end-0 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">
|
||||
<ul id="serverTabs" class="nav nav-pills py-3 text-start px-sm-3" role="tablist">
|
||||
<li class="nav-item me-auto">
|
||||
<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 text-body-secondary"></h6>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item" 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-md-inline">Subscriptions</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" 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-md-inline">Content Filters</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" 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-md-inline">Message Styles</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item me-0" 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-md-inline">Tracked Content</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item d-none">
|
||||
<div class="dropdown">
|
||||
<button class="nav-link rounded-1 dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu rounded-1">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2">
|
||||
<i class="bi bi-trash3"></i>
|
||||
<span class="ms-2">Delete Server Data</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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">
|
||||
{% 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/confirm.html" %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block javascript %}
|
||||
<script id="serverItemTemplate" type="text/template">
|
||||
<li class="nav-item server-item" data-id="" data-guild-id="">
|
||||
<button type="button" class="btn border-0 server-item-selector w-100 rounded-1 position-relative">
|
||||
<div class="d-flex justify-content-start align-items-center w-100">
|
||||
<img src="" alt="Guild Icon" class="rounded-1 small d-flex justify-content-center align-items-center" width="45" height="45">
|
||||
<div class="server-item-labels small text-start ps-2 w-100 flex-shrink-1 overflow-hidden">
|
||||
<div class="js-guildName fw-bold text-truncate"></div>
|
||||
<div class="js-guildId text-truncate font-monospace"></div>
|
||||
</div>
|
||||
</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/tables.js' %}"></script>
|
||||
<script src="{% static 'home/js/servers.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 %}
|
38
apps/home/templates/home/modals/confirm.html
Normal file
38
apps/home/templates/home/modals/confirm.html
Normal file
@ -0,0 +1,38 @@
|
||||
<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="okModal" 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 ms-3 ms-0 px-4 modal-dismiss-btn" tabindex="2">
|
||||
<i class="bi"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
127
apps/home/templates/home/modals/editFilter.html
Normal file
127
apps/home/templates/home/modals/editFilter.html
Normal file
@ -0,0 +1,127 @@
|
||||
|
||||
<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-dropdownparent="#filterFormModal" 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>
|
||||
|
||||
<!-- <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-12">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="filterWhitelist" class="form-check-label">Whitelist</label>
|
||||
<input type="checkbox" id="filterWhitelist" name="filterWhitelist" class="form-check-input" tabindex="4">
|
||||
<div class="form-text">Allow only matches, instead of blocking them.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<label for="filterInsensitive" class="form-check-label">Case Insensitive</label>
|
||||
<input type="checkbox" id="filterInsensitive" name="filterInsensitive" class="form-check-input" tabindex="5">
|
||||
<div class="form-text">Ignore case sensitivity when matching.</div>
|
||||
</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> -->
|
@ -19,14 +19,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-switch ps-0">
|
||||
<label for="guildSettingsActive" class="form-check-label mb-2">Server Active?</label>
|
||||
<br>
|
||||
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input ms-0 mt-0">
|
||||
<br>
|
||||
<div class="form-text">Is this server active?</div>
|
||||
<div class="form-check form-switch">
|
||||
<label for="guildSettingsActive" class="form-check-label">Server Enabled</label>
|
||||
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input" role="switch">
|
||||
<div class="form-text">Disabled servers will not process Subscriptions.</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 class="modal-footer px-4">
|
||||
<button type="submit" class="btn btn-primary rounded-1 ms-3 me-0">Save Changes</button>
|
98
apps/home/templates/home/modals/editStyle.html
Normal file
98
apps/home/templates/home/modals/editStyle.html
Normal file
@ -0,0 +1,98 @@
|
||||
<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>
|
||||
<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-dropdownparent="#styleFormModal" 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-dropdownparent="#styleFormModal" 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" 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-dropdownparent="#subFormModal" data-field="message_style" 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-dropdownparent="#subFormModal" 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-dropdownparent="#subFormModal" 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>
|
||||
<label for="subUniqueRules" class="form-label">Uniqueness Rules</label>
|
||||
<select name="subUniqueRules" id="subUniqueRules" class="select-2" multiple data-dropdownparent="#subFormModal" 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 d-none">Disabled Subscriptions are ignored.</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>
|
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-search-buttons">
|
||||
<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 align-middle"></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 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-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 align-middle"></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-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">
|
||||
<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 align-middle"></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 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-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="subTable" class="table table-hover align-middle"></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.contrib.auth.decorators import login_required
|
||||
|
||||
from .views import IndexView
|
||||
from .views import IndexView, GuildsView, ChannelsView
|
||||
|
||||
urlpatterns = [
|
||||
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,21 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from django.views.generic import TemplateView
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.shortcuts import redirect
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from django.views.generic import TemplateView, View
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from apps.home.models import Server, DiscordChannel
|
||||
from apps.authentication.models import DiscordUser, ServerMember
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
@ -9,3 +24,158 @@ class IndexView(TemplateView):
|
||||
"""
|
||||
|
||||
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):
|
||||
|
||||
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)
|
||||
|
||||
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}"}
|
||||
)
|
||||
status = response.status_code
|
||||
guild_data = response.json()
|
||||
|
||||
if status != 200:
|
||||
return JsonResponse(guild_data, status=status, safe=False)
|
||||
|
||||
# guilds where the user either administrates or owns
|
||||
cleaned_guild_data = []
|
||||
|
||||
for item in guild_data:
|
||||
cleaned_data = await self.setup_server(request.user, item)
|
||||
if cleaned_data:
|
||||
cleaned_guild_data.append(cleaned_data)
|
||||
|
||||
return JsonResponse(cleaned_guild_data, safe=False)
|
||||
|
||||
async def setup_server(self, user: DiscordUser, data: dict):
|
||||
is_owner = data["owner"]
|
||||
permissions = data["permissions"]
|
||||
admin_perm = 1 << 3
|
||||
|
||||
# Ignore servers where the user isn't an administrator or owner
|
||||
if not ((int(permissions) & admin_perm) == admin_perm or is_owner):
|
||||
await self.delete_member(user, data["id"])
|
||||
return
|
||||
|
||||
server = await Server.objects.aupdate_or_create(
|
||||
id=data["id"],
|
||||
defaults={
|
||||
"name": data["name"],
|
||||
"icon_hash": data["icon"]
|
||||
}
|
||||
)
|
||||
|
||||
await ServerMember.objects.aupdate_or_create(
|
||||
user=user,
|
||||
server=server[0],
|
||||
defaults={
|
||||
"permissions": permissions,
|
||||
"is_owner": is_owner
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def delete_member(user: DiscordUser, server_id: int):
|
||||
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")
|
||||
server = await Server.objects.aget(pk=guild_id)
|
||||
|
||||
if not ServerMember.objects.filter(server=server, user=request.user).aexists():
|
||||
raise PermissionDenied("You aren't a member of this server.")
|
||||
|
||||
channels_data, status = await self._get_channel_data(guild_id)
|
||||
if status != 200:
|
||||
return JsonResponse(channels_data, status=status, safe=False)
|
||||
|
||||
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`.
|
||||
"""
|
||||
|
||||
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,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>
|
@ -25,7 +25,7 @@ required_env_vars = (
|
||||
|
||||
for var in required_env_vars:
|
||||
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)
|
||||
|
||||
# SECURITY WARNING: This is sensitive data, keep secure!
|
||||
SECRET_KEY = env('SECRET_KEY', default="unsecure-default-secret-key")
|
||||
@ -78,7 +78,7 @@ AUTH_USER_MODEL = "authentication.DiscordUser"
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / "apps/templates"],
|
||||
'DIRS': [BASE_DIR / "templates"],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
@ -237,7 +237,7 @@ STATIC_URL = '/static/'
|
||||
|
||||
# Extra places for collectstatic to find static files.
|
||||
STATICFILES_DIRS = (
|
||||
BASE_DIR / 'apps/static',
|
||||
BASE_DIR / 'static',
|
||||
)
|
||||
|
||||
# Media Files
|
||||
@ -257,5 +257,15 @@ REST_FRAMEWORK = {
|
||||
'anon': '100/day',
|
||||
'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"
|
||||
}
|
||||
|
||||
# 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,3 +1,4 @@
|
||||
anyio==4.6.0
|
||||
asgiref==3.8.1
|
||||
bump2version==1.0.1
|
||||
certifi==2024.2.2
|
||||
@ -7,6 +8,9 @@ django-environ==0.11.2
|
||||
django-filter==24.2
|
||||
djangorestframework==3.15.1
|
||||
gunicorn==23.0.0
|
||||
h11==0.14.0
|
||||
httpcore==1.0.5
|
||||
httpx==0.27.2
|
||||
idna==3.7
|
||||
packaging==24.1
|
||||
psycopg2==2.9.9
|
||||
|
@ -74,4 +74,35 @@
|
||||
.w-xxl-75 { width: 75%; }
|
||||
.w-xxl-100 { width: 100%; }
|
||||
.w-xxl-auto { width: auto; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Navbar */
|
||||
|
||||
#navCollapse .navbar-nav {
|
||||
height: auto;
|
||||
padding-right: none;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
#navCollapse .navbar-nav {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-btn {
|
||||
text-align: start;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.navbar-btn:hover {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
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