Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
ad982267a1 | |||
5237f6fbf9 | |||
4d9877963e |
243
CHANGELOG.md
243
CHANGELOG.md
@ -1,214 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
**v0.3.4**
|
||||
|
||||
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).
|
||||
- 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
|
||||
|
||||
## [Unreleased] [0.4.1] - xxxx-xx-xx
|
||||
**v0.3.3**
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
- 'Invalid' appearance for select-2 controls, upon bad form submission
|
||||
- Shorthand functions for setting/clearing the "spot" classes on ".sidebar-item"s
|
||||
**v0.3.2**
|
||||
|
||||
### Fixed
|
||||
- Fix: invite link refered to wrong Discord application, because the client Id was hard coded
|
||||
- Enhancement: enabled pagination for the `/api/guild-settings/` endpoint
|
||||
|
||||
- Storing Discord-provided snowflake ID's in a postgresql database. Now using 'PositiveLargeIntegerField' to support this
|
||||
- Sidebar server "placeholder" elements running offscreen on desktop - fixed by reducing them by 2
|
||||
- Theme button missing icon on page load. Created an init function for properly setting up the theme on page load.
|
||||
**v0.3.1**
|
||||
|
||||
### Changed
|
||||
- Fix: issues brought from previous version
|
||||
|
||||
- Keep the hover state of sidebar buttons while their respective dropdown's are showing
|
||||
- Made the 'channels' field on subscriptions required, at least one channel must be chosen
|
||||
- Deleting a used message style, will cause subscriptions using it to fall back to their default message styles
|
||||
**breaking changes v.0.3.0**
|
||||
|
||||
## [Unreleased] [0.4.0] - xxxx-xx-xx [BREAKING]
|
||||
- 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`
|
||||
|
||||
### Added
|
||||
**v0.2.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
|
||||
- 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>`.
|
||||
|
||||
### Fixed
|
||||
**v0.2.1**
|
||||
|
||||
- Footer links pointing to older domain
|
||||
- Tracked content ListView API incorrectly ordering by oldest first, instead of newest first
|
||||
- Enhancement: Added confirmation modal for closing a server
|
||||
|
||||
### Changed
|
||||
**v0.2.0**
|
||||
|
||||
- General rewrite of entire web interface
|
||||
- General rewrite of entire backend
|
||||
- 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
|
||||
- Moved footer items to bottom of sidebar
|
||||
- Changed style of navbar buttons
|
||||
- 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
|
||||
|
||||
### Removed
|
||||
**v0.1.14**
|
||||
|
||||
- Unused code for the legacy 'server settings tab' from before it became a modal
|
||||
- Fix: layout issue for the 'select a server' page was off-centre
|
||||
|
||||
## [0.3.4] - 2024-09-12
|
||||
**v0.1.13**
|
||||
|
||||
### Added
|
||||
- Docs: Start of changelog
|
||||
- Fix: remove db flush from entrypoint file
|
||||
|
||||
- Add wiki link button to footer
|
||||
**v0.1.0**
|
||||
|
||||
### Fixed
|
||||
|
||||
- Refresh data tables after deleting any number of entries (corbz/PYRSS-Website#38)
|
||||
- Select2 dropdown search bars always using light theme, regardless of user choice
|
||||
- Fixed "ordering=unknown" in api calls by only applying ordering if it's truthy
|
||||
- Exception caused by unfinished queryset method on the `/api/guild-settings/` endpoint
|
||||
- Several potential xss attack vectors
|
||||
- 'Add Pyrss' button incorrectly not opening in new tab
|
||||
|
||||
### Changed
|
||||
|
||||
- `data-field` implementation for subscription form
|
||||
- Improved the offcanvas navbar on smaller screens, to be less ugly
|
||||
- Clearer help label on the 'Add Server' modal/form
|
||||
- rewrote `confirmDeleteModal` into less specific `confirmationModal` with specifiable styles
|
||||
|
||||
## [0.3.3] - 2024-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added font: sora
|
||||
- Added font: Atkinson Hyperlegible
|
||||
- added license file
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a padding issue with link-style buttons in table cells
|
||||
|
||||
### Changed
|
||||
|
||||
- All table cells no longer wrap text
|
||||
- `DISCORD_SCOPES` as env var, rather than hard coded
|
||||
|
||||
## [0.3.2] - 2024-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Invite link refered to wrong Discord application, because the client Id was hard coded
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled pagination for the `/api/guild-settings/` endpoint
|
||||
|
||||
## [0.3.1] - 2024-08-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Colour embeds with multiple hashtag characters, bug from [0.3.0]
|
||||
|
||||
## [0.3.0] - 2024-08-16 [BREAKING]
|
||||
|
||||
### Added
|
||||
|
||||
- `active` flag added as server setting, soft-toggles activeness of associated subscriptions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bad text alignment for some table button links
|
||||
- Incorrect margin on 'server settings' & 'new server' modal buttons
|
||||
- Table "X Results" text now correctly plural only if `results > 1`
|
||||
|
||||
### Changed
|
||||
|
||||
- store guild settings in separate model
|
||||
- moved server settinsg from tab to pop-out modal
|
||||
- server `default_embed_colour` setting will be reset due to changes on how settings are stored
|
||||
|
||||
## [0.2.2] - 2024-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added open graph meta tags
|
||||
|
||||
### Fixed
|
||||
|
||||
- csrf trusted origins warning, from not including url protocol
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
|
||||
|
||||
## [0.2.1] - 2024-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added confirmation modal for closing a server
|
||||
|
||||
## [0.2.0] - 2024-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- 'Go Back' button on the server page, takes the user to the 'select a server' page.
|
||||
- Documented `static/home/servers.js` with comments
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved warning when server doesn't include bot member
|
||||
- Made it easier to update labels to the current server's details
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed some unused/unreferenced code
|
||||
|
||||
## [0.1.15] - 2024-08-13
|
||||
|
||||
## [0.1.14] - 2024-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- layout issue for the 'select a server' page was off-centre
|
||||
|
||||
## [0.1.13] - 2024-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- Start of changelog
|
||||
|
||||
### Removed
|
||||
|
||||
- remove db flush from entrypoint file
|
||||
|
||||
## [0.1.12] - 2024-08-12
|
||||
|
||||
## [0.1.11] - 2024-08-12
|
||||
|
||||
## [0.1.10] - 2024-08-12
|
||||
|
||||
## [0.1.9] - 2024-08-12
|
||||
|
||||
## [0.1.8] - 2024-08-12
|
||||
|
||||
## [0.1.7] - 2024-08-12
|
||||
|
||||
## [0.1.6] - 2024-08-11
|
||||
|
||||
## [0.1.5] - 2024-08-11
|
||||
|
||||
## [0.1.4] - 2024-08-09
|
||||
|
||||
## [0.1.3] - 2024-08-08
|
||||
|
||||
## [0.1.2] - 2024-08-05
|
||||
|
||||
## [0.1.1] - 2024-08-04
|
||||
|
||||
## [0.1.0] - 2024-08-04
|
||||
- Initial Release
|
||||
|
@ -1,3 +0,0 @@
|
||||
|
||||
class NotAMemberError(Exception):
|
||||
pass
|
@ -2,19 +2,22 @@
|
||||
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
from apps.home.models import Server
|
||||
from apps.authentication.models import ServerMember
|
||||
|
||||
|
||||
class HasServerAccess(BasePermission):
|
||||
class UserHasDiscordPermissions(BasePermission):
|
||||
"""
|
||||
An object permission class, the object must have a 'server' attribute.
|
||||
Permission to ensure that the user is permitted to make
|
||||
changes on behalf of the server they are representing.
|
||||
"""
|
||||
|
||||
message = "You lack administrator access to this server"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not hasattr(obj, "server"):
|
||||
raise Exception(f"obj '{obj}' must have attr 'server'")
|
||||
|
||||
return ServerMember.objects.filter(user=request.user, server=obj.server).exists()
|
||||
|
||||
# class SubscriptionServerMember(BasePermission):
|
||||
# """
|
||||
# Permission for each subscription that omits the sub if
|
||||
# the request user isn't a member of it's server.
|
||||
# """
|
||||
|
||||
# def has_object_permission(self, request, view, obj):
|
||||
|
||||
# return obj.server in request.user.servers
|
@ -1,27 +0,0 @@
|
||||
|
||||
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,26 +1,15 @@
|
||||
# -*- 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 (
|
||||
Server,
|
||||
ContentFilter,
|
||||
MessageMutator,
|
||||
MessageStyle,
|
||||
DiscordChannel,
|
||||
Subscription,
|
||||
Content,
|
||||
SubscriptionRecommendation
|
||||
)
|
||||
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
|
||||
|
||||
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.
|
||||
|
||||
@ -120,233 +109,108 @@ class DynamicModelSerializer(serializers.ModelSerializer):
|
||||
abstract = True
|
||||
|
||||
|
||||
# region Servers
|
||||
|
||||
class ServerSerializer(DynamicModelSerializer):
|
||||
id = serializers.CharField()
|
||||
class SubChannelSerializer(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for SubChannel Model.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Server
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"icon_hash",
|
||||
"is_bot_operational",
|
||||
"active"
|
||||
)
|
||||
model = SubChannel
|
||||
fields = ("id", "channel_id", "channel_name", "subscription")
|
||||
|
||||
|
||||
# region Filters
|
||||
class FilterSerializer(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the Filter Model.
|
||||
"""
|
||||
|
||||
class ContentFilterSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = ContentFilter
|
||||
fields = (
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"is_whitelist"
|
||||
)
|
||||
model = Filter
|
||||
fields = ("id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id")
|
||||
|
||||
|
||||
# region Msg Mutators
|
||||
class ArticleMutatorSerializer(DynamicModelSerializer):
|
||||
|
||||
class MessageMutatorSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = MessageMutator
|
||||
model = ArticleMutator
|
||||
fields = ("id", "name", "value")
|
||||
|
||||
|
||||
# region Msg Styles
|
||||
class SubscriptionSerializer_GET(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the Subscription Model.
|
||||
"""
|
||||
|
||||
class MessageStyleSerializer(DynamicModelSerializer):
|
||||
title_mutator_detail = serializers.SerializerMethodField()
|
||||
description_mutator_detail = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MessageStyle
|
||||
fields = (
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"is_embed",
|
||||
"colour",
|
||||
"is_hyperlinked",
|
||||
"show_author",
|
||||
"show_timestamp",
|
||||
"show_images",
|
||||
"fetch_images",
|
||||
"title_mutator",
|
||||
"title_mutator_detail",
|
||||
"description_mutator",
|
||||
"description_mutator_detail",
|
||||
"auto_created"
|
||||
)
|
||||
read_only_fields = ("auto_created",)
|
||||
|
||||
def get_title_mutator_detail(self, obj: MessageStyle):
|
||||
request = self.context.get("request")
|
||||
if request and request.method == "GET":
|
||||
return MessageMutatorSerializer(obj.title_mutator).data
|
||||
return {}
|
||||
|
||||
def get_description_mutator_detail(self, obj: MessageStyle):
|
||||
request = self.context.get("request")
|
||||
if request and request.method == "GET":
|
||||
return MessageMutatorSerializer(obj.description_mutator).data
|
||||
return {}
|
||||
|
||||
|
||||
# region Subscriptions
|
||||
|
||||
class DiscordChannelField(serializers.PrimaryKeyRelatedField):
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
data = int(data)
|
||||
except (TypeError, ValueError):
|
||||
self.fail("invalid", pk_value=data)
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def to_representation(self, value):
|
||||
return str(value.pk)
|
||||
|
||||
|
||||
class NestedDiscordChannelSerializer(DynamicModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DiscordChannel
|
||||
fields = ("id", "name", "is_nsfw")
|
||||
|
||||
|
||||
class SubscriptionSerializer(DynamicModelSerializer):
|
||||
filters = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ContentFilter.objects.all(),
|
||||
many=True
|
||||
)
|
||||
channels = DiscordChannelField(
|
||||
queryset=DiscordChannel.objects.all(),
|
||||
many=True,
|
||||
required=True,
|
||||
allow_empty=False
|
||||
)
|
||||
channels_detail = serializers.SerializerMethodField()
|
||||
filters_detail = serializers.SerializerMethodField()
|
||||
message_style = serializers.PrimaryKeyRelatedField(
|
||||
queryset=MessageStyle.objects.all(),
|
||||
required=True,
|
||||
allow_null=False
|
||||
)
|
||||
message_style_detail = serializers.SerializerMethodField()
|
||||
article_title_mutators = ArticleMutatorSerializer(many=True)
|
||||
article_desc_mutators = ArticleMutatorSerializer(many=True)
|
||||
active = serializers.BooleanField(initial=True)
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = (
|
||||
"id",
|
||||
"server",
|
||||
"name",
|
||||
"url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"extra_notes",
|
||||
"active",
|
||||
"publish_threshold",
|
||||
"channels",
|
||||
"channels_detail",
|
||||
"filters",
|
||||
"filters_detail",
|
||||
"message_style",
|
||||
"message_style_detail"
|
||||
"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"
|
||||
)
|
||||
|
||||
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 []
|
||||
|
||||
def get_filters_detail(self, obj: Subscription):
|
||||
request = self.context.get("request")
|
||||
if request.method == "GET":
|
||||
return ContentFilterSerializer(obj.filters.all(), many=True).data
|
||||
return []
|
||||
class SubscriptionSerializer_POST(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the Subscription Model.
|
||||
"""
|
||||
|
||||
def get_message_style_detail(self, obj: Subscription):
|
||||
request = self.context.get("request")
|
||||
if request.method == "GET":
|
||||
return MessageStyleSerializer(obj.message_style).data
|
||||
return {}
|
||||
|
||||
def 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."}
|
||||
)
|
||||
|
||||
# Prevent assigning more channels than permitted
|
||||
channels = data.get("channels")
|
||||
if len(channels) > settings.MAX_CHANNELS_PER_SUBSCRIPTION:
|
||||
raise serializers.ValidationError(
|
||||
{"channels": "Please select 5 channels or fewer."}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# region Content
|
||||
|
||||
class ContentSerializer(DynamicModelSerializer):
|
||||
class Meta:
|
||||
model = Content
|
||||
model = Subscription
|
||||
fields = (
|
||||
"id",
|
||||
"subscription",
|
||||
"item_id",
|
||||
"item_guid",
|
||||
"item_url",
|
||||
"item_title",
|
||||
"item_description",
|
||||
"item_content_hash",
|
||||
"item_image_url",
|
||||
"item_thumbnail_url",
|
||||
"item_published",
|
||||
"item_author",
|
||||
"item_author_url",
|
||||
"item_feed_title",
|
||||
"item_feed_url",
|
||||
"blocked"
|
||||
"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"
|
||||
)
|
||||
|
||||
class SubscriptionRecommendationSerializer(DynamicModelSerializer):
|
||||
|
||||
class SavedGuildSerializer(DynamicModelSerializer):
|
||||
"""
|
||||
Serializer for the SavedGuild model.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = SubscriptionRecommendation
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"url"
|
||||
)
|
||||
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,63 +4,62 @@ from django.urls import path, include
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from .views import (
|
||||
Server_ListView,
|
||||
Server_DetailView,
|
||||
ContentFilter_ListView,
|
||||
ContentFilter_DetailView,
|
||||
MessageMutator_ListView,
|
||||
MessageMutator_DetailView,
|
||||
MessageStyle_ListView,
|
||||
MessageStyle_DetailView,
|
||||
SubChannel_ListView,
|
||||
SubChannel_DetailView,
|
||||
Filter_ListView,
|
||||
Filter_DetailView,
|
||||
Subscription_ListView,
|
||||
Subscription_DetailView,
|
||||
Content_ListView,
|
||||
Content_DetailView,
|
||||
SubscriptionRecommendations_ListView
|
||||
Subscription_SubChannelView,
|
||||
SavedGuild_ListView,
|
||||
SavedGuild_DetailView,
|
||||
TrackedContent_ListView,
|
||||
TrackedContent_DetailView,
|
||||
ArticleMutator_ListView,
|
||||
ArticleMutator_DetailView,
|
||||
GuildSettings_ListView,
|
||||
GuildSettings_DetailView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
|
||||
path("api-token-auth/", obtain_auth_token),
|
||||
|
||||
# region Servers
|
||||
path("servers/", include([
|
||||
path("", Server_ListView.as_view()),
|
||||
path("<int:pk>/", Server_DetailView.as_view())
|
||||
path("subchannel/", include([
|
||||
path("", SubChannel_ListView.as_view(), name="subchannel"),
|
||||
path("<str:pk>/", SubChannel_DetailView.as_view(), name="subchannel-detail")
|
||||
])),
|
||||
|
||||
# region Filters
|
||||
path("filters/", include([
|
||||
path("", ContentFilter_ListView.as_view()),
|
||||
path("<int:pk>/", ContentFilter_DetailView.as_view())
|
||||
path("filter/", include([
|
||||
path("", Filter_ListView.as_view(), name="filter"),
|
||||
path("<str:pk>/", Filter_DetailView.as_view(), name="filter-detail")
|
||||
])),
|
||||
|
||||
# region Message Mutators
|
||||
path("message-mutators/", include([
|
||||
path("", MessageMutator_ListView.as_view()),
|
||||
path("<int:pk>/", MessageMutator_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 Styles
|
||||
path("message-styles/", include([
|
||||
path("", MessageStyle_ListView.as_view()),
|
||||
path("<int:pk>/", MessageStyle_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 Subscriptions
|
||||
path("subscriptions/", include([
|
||||
path("", Subscription_ListView.as_view()),
|
||||
path("<int:pk>/", Subscription_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 Content
|
||||
path("content/", include([
|
||||
path("", Content_ListView.as_view()),
|
||||
path("<int:pk>/", Content_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 Recommendations
|
||||
path("subscription-recommendations/", include([
|
||||
path("", SubscriptionRecommendations_ListView.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")
|
||||
])),
|
||||
]
|
||||
|
@ -2,37 +2,29 @@
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
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 permissions, filters, generics, status
|
||||
from rest_framework import status, permissions, filters, generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.filters import BaseFilterBackend
|
||||
|
||||
from apps.home.models import (
|
||||
Server,
|
||||
ContentFilter,
|
||||
MessageMutator,
|
||||
MessageStyle,
|
||||
Subscription,
|
||||
Content,
|
||||
SubscriptionRecommendation
|
||||
)
|
||||
from apps.authentication.models import DiscordUser, ServerMember
|
||||
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
|
||||
from apps.authentication.models import DiscordUser
|
||||
from .metadata import ExpandedMetadata
|
||||
from .serializers import (
|
||||
ServerSerializer,
|
||||
ContentFilterSerializer,
|
||||
MessageMutatorSerializer,
|
||||
MessageStyleSerializer,
|
||||
SubscriptionSerializer,
|
||||
ContentSerializer,
|
||||
SubscriptionRecommendationSerializer
|
||||
SubChannelSerializer,
|
||||
FilterSerializer,
|
||||
SubscriptionSerializer_GET,
|
||||
SubscriptionSerializer_POST,
|
||||
SavedGuildSerializer,
|
||||
TrackedContentSerializer_GET,
|
||||
TrackedContentSerializer_POST,
|
||||
ArticleMutatorSerializer,
|
||||
GuildSettingsSerializer
|
||||
)
|
||||
from .permissions import HasServerAccess
|
||||
from .errors import NotAMemberError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -41,7 +33,6 @@ class DefaultPagination(PageNumberPagination):
|
||||
"""Default class for pagination in API views."""
|
||||
|
||||
page_size = 10
|
||||
page_query_param = "page"
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 25
|
||||
|
||||
@ -50,269 +41,580 @@ def is_automated_admin(user):
|
||||
return user.user_type == DiscordUser.USER_TYPES.AUTOMATED_USER and user.is_superuser
|
||||
|
||||
|
||||
class ListView(generics.ListAPIView):
|
||||
# =================================================================================================
|
||||
# SubChannel Views
|
||||
|
||||
class SubChannel_ListView(generics.ListCreateAPIView):
|
||||
"""
|
||||
View to provide a list of SubChannel model instances.
|
||||
Can also be used to create a new instance.
|
||||
|
||||
Supports: GET, POST
|
||||
"""
|
||||
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
pagination_class = DefaultPagination
|
||||
metadata_class = ExpandedMetadata
|
||||
serializer_class = SubChannelSerializer
|
||||
queryset = SubChannel.objects.all().order_by("id")
|
||||
|
||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
|
||||
|
||||
class ListCreateView(generics.ListCreateAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
pagination_class = DefaultPagination
|
||||
metadata_class = ExpandedMetadata
|
||||
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
|
||||
|
||||
class DetailView(generics.RetrieveAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
|
||||
class ChangableDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
|
||||
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
|
||||
filterset_fields = ["id", "channel_id", "channel_name", "subscription"]
|
||||
search_fields = ["channel_name"]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Server.objects.all().order_by("id")
|
||||
return SubChannel.objects.all()
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return Server.objects.filter(id__in=servers).order_by("id")
|
||||
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 Server_DetailView(DeletableDetailView):
|
||||
serializer_class = ServerSerializer
|
||||
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 Server.objects.all()
|
||||
return SubChannel.objects.all()
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return Server.objects.filter(id__in=servers)
|
||||
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
|
||||
guild_ids = [guild.guild_id for guild in saved_guilds]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
server = self.get_object()
|
||||
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
|
||||
|
||||
if not self.request.user.is_superuser:
|
||||
member = ServerMember.objects.get(server=server, user=request.user)
|
||||
if not member.is_owner:
|
||||
|
||||
# =================================================================================================
|
||||
# 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
|
||||
"""
|
||||
|
||||
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": "Only the owner can destroy server data."},
|
||||
{"detail": "You are forbidden from viewing this subscription"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
subscription = subscriptions.filter(id=kwargs["pk"]).first()
|
||||
|
||||
if not subscription:
|
||||
return Response(
|
||||
{"detail": "not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# region Filters
|
||||
channels = SubChannel.objects.filter(subscription=subscription)
|
||||
channels.delete();
|
||||
|
||||
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
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return ContentFilter.objects.all().order_by("name")
|
||||
# =================================================================================================
|
||||
# SavedGuild Views
|
||||
|
||||
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):
|
||||
if self.request.user.is_superuser:
|
||||
return ContentFilter.objects.all()
|
||||
|
||||
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):
|
||||
if self.request.user.is_superuser:
|
||||
return MessageStyle.objects.all().order_by("id")
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return MessageStyle.objects.filter(server__in=servers).order_by("id")
|
||||
|
||||
|
||||
class MessageStyle_DetailView(ChangableDetailView):
|
||||
serializer_class = MessageStyleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return MessageStyle.objects.all()
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return MessageStyle.objects.filter(server__in=servers)
|
||||
|
||||
|
||||
# region Subscriptions
|
||||
|
||||
class Subscription_ListView(ListCreateView):
|
||||
filterset_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "publish_threshold", "filters", "message_style")
|
||||
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):
|
||||
if self.request.user.is_superuser:
|
||||
return Subscription.objects.all().order_by("id")
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return Subscription.objects.filter(server__in=servers).order_by("id")
|
||||
|
||||
|
||||
class Subscription_DetailView(ChangableDetailView):
|
||||
serializer_class = SubscriptionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Subscription.objects.all()
|
||||
|
||||
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
|
||||
return Subscription.objects.filter(server__in=servers)
|
||||
|
||||
|
||||
# region Content
|
||||
|
||||
|
||||
class ContentFilterBackend(BaseFilterBackend):
|
||||
class SavedGuild_ListView(generics.ListCreateAPIView):
|
||||
"""
|
||||
If `match_any=true` in querystring, matches 'any' params instead
|
||||
of 'all' params.
|
||||
View to provide a list of SavedGuild model instances.
|
||||
Can also be used to create a new instance.
|
||||
|
||||
Supports: GET, POST
|
||||
"""
|
||||
|
||||
_MATCH_ANY_PARAM = "match_any"
|
||||
_IGNORE_PARAMS = [
|
||||
DefaultPagination.page_query_param,
|
||||
DefaultPagination.page_size_query_param,
|
||||
"search",
|
||||
"subscription"
|
||||
]
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
filters = Q()
|
||||
match_any = request.query_params.get(self._MATCH_ANY_PARAM, "").lower() == "true"
|
||||
log.debug(f"matching any against content: {match_any}")
|
||||
pagination_class = None
|
||||
serializer_class = SavedGuildSerializer
|
||||
metadata_class = ExpandedMetadata
|
||||
|
||||
for param, value in request.query_params.items():
|
||||
if param in self._IGNORE_PARAMS or param == self._MATCH_ANY_PARAM:
|
||||
continue
|
||||
|
||||
query = Q(**{param: value})
|
||||
|
||||
if match_any:
|
||||
filters |= query
|
||||
else:
|
||||
filters &= query
|
||||
|
||||
log.debug(query)
|
||||
|
||||
queryset_filter = queryset.filter(filters)
|
||||
log.debug(queryset_filter.query)
|
||||
return queryset_filter
|
||||
|
||||
|
||||
class Content_ListView(ListCreateView):
|
||||
search_fields = (
|
||||
"item_id",
|
||||
"item_guid",
|
||||
"item_url",
|
||||
"item_title",
|
||||
"item_description",
|
||||
"item_content_hash",
|
||||
"item_image_url",
|
||||
"item_thumbnail_url",
|
||||
"item_published",
|
||||
"item_author",
|
||||
"item_author_url",
|
||||
"item_feed_title",
|
||||
"item_feed_url"
|
||||
)
|
||||
filterset_fields = search_fields + ("id", "subscription", "subscription__server")
|
||||
ordering_fields = filterset_fields
|
||||
serializer_class = ContentSerializer
|
||||
filter_backends = [
|
||||
ContentFilterBackend,
|
||||
filters.SearchFilter,
|
||||
rest_filters.DjangoFilterBackend,
|
||||
filters.OrderingFilter
|
||||
]
|
||||
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 Content.objects.all().order_by("-subscription__created_at", "id")
|
||||
return SavedGuilds.objects.all()
|
||||
|
||||
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")
|
||||
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 Content_DetailView(ChangableDetailView):
|
||||
serializer_class = ContentSerializer
|
||||
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 Content.objects.all()
|
||||
return SavedGuilds.objects.all()
|
||||
|
||||
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)
|
||||
return SavedGuilds.objects.filter(added_by=self.request.user)
|
||||
|
||||
|
||||
# region Sub Recommendations
|
||||
# =================================================================================================
|
||||
# GuildSettings Views
|
||||
|
||||
class SubscriptionRecommendations_ListView(ListView):
|
||||
queryset = SubscriptionRecommendation.objects.all().order_by("id")
|
||||
serializer_class = SubscriptionRecommendationSerializer
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
serializer_class = ArticleMutatorSerializer
|
||||
queryset = ArticleMutator.objects.all().order_by("id")
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DiscordUser, ServerMember
|
||||
from .models import DiscordUser
|
||||
|
||||
|
||||
@admin.register(DiscordUser)
|
||||
@ -10,8 +10,3 @@ 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,8 +1,6 @@
|
||||
# Generated by Django 5.0.4 on 2024-09-24 14:05
|
||||
# Generated by Django 5.0.4 on 2024-05-03 13:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -12,7 +10,6 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('home', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -29,8 +26,6 @@ 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')),
|
||||
@ -42,14 +37,4 @@ 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
@ -0,0 +1,20 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# 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,25 +159,3 @@ 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
|
||||
|
@ -3,12 +3,17 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth.views import LogoutView
|
||||
|
||||
from .views import DiscordLoginAction, DiscordLoginRedirect, Login
|
||||
from .views import DiscordLoginAction, DiscordLoginRedirect, Login, GuildsView, GuildChannelsView, SaveGuildView
|
||||
|
||||
|
||||
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("logout/", LogoutView.as_view(), name="logout"),
|
||||
|
||||
path("guilds/", GuildsView.as_view(), name="guilds"),
|
||||
path("channels/", GuildChannelsView.as_view(), name="channels"),
|
||||
path("save-guild/", SaveGuildView.as_view(), name="save-guild")
|
||||
|
||||
]
|
||||
|
@ -49,8 +49,7 @@ class DiscordLoginRedirect(View):
|
||||
discord_user = authenticate(request, discord_user_data=raw_user_data)
|
||||
login(request, discord_user)
|
||||
|
||||
redirect_url = request.GET.get("redirect") or "home:index"
|
||||
return redirect(redirect_url)
|
||||
return redirect("home:index")
|
||||
|
||||
def exchange_code(self, code: str) -> dict:
|
||||
"""
|
||||
@ -119,6 +118,34 @@ 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):
|
||||
@ -130,4 +157,9 @@ class GuildChannelsView(View):
|
||||
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
||||
)
|
||||
|
||||
return JsonResponse(response.json(), status=response.status_code, safe=False)
|
||||
return JsonResponse(response.json(), safe=False)
|
||||
|
||||
|
||||
class SaveGuildView(View):
|
||||
|
||||
pass
|
||||
|
@ -2,108 +2,52 @@
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
Server,
|
||||
ContentFilter,
|
||||
MessageMutator,
|
||||
MessageStyle,
|
||||
DiscordChannel,
|
||||
Subscription,
|
||||
Content
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"icon_hash",
|
||||
"is_bot_operational",
|
||||
"active"
|
||||
]
|
||||
list_display_links = ["id"]
|
||||
|
||||
|
||||
@admin.register(ContentFilter)
|
||||
class ContentFilterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"server",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"is_whitelist"
|
||||
]
|
||||
list_display_links = ["name"]
|
||||
|
||||
|
||||
@admin.register(MessageMutator)
|
||||
class MessageMutatorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"value"
|
||||
]
|
||||
list_display_links = ["name"]
|
||||
|
||||
|
||||
@admin.register(MessageStyle)
|
||||
class MessageStyleAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"server",
|
||||
"is_embed",
|
||||
"colour",
|
||||
"is_hyperlinked",
|
||||
"show_author",
|
||||
"show_timestamp",
|
||||
"show_images",
|
||||
"fetch_images",
|
||||
"title_mutator",
|
||||
"description_mutator",
|
||||
"auto_created"
|
||||
]
|
||||
list_display_links = ["name"]
|
||||
|
||||
|
||||
@admin.register(DiscordChannel)
|
||||
class DiscordChannelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"server",
|
||||
"is_nsfw"
|
||||
]
|
||||
list_display_links = ["name"]
|
||||
from .models import Subscription, SavedGuilds, Filter, SubChannel, TrackedContent, ArticleMutator, GuildSettings
|
||||
|
||||
|
||||
@admin.register(Subscription)
|
||||
class Subscription(admin.ModelAdmin):
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"server",
|
||||
"url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"extra_notes",
|
||||
"active",
|
||||
"message_style"
|
||||
"id", "name", "url", "guild_id",
|
||||
"creation_datetime", "active"
|
||||
]
|
||||
|
||||
@admin.register(SubChannel)
|
||||
class SubChannelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "channel_id", "subscription"
|
||||
]
|
||||
list_display_links = ["name"]
|
||||
|
||||
|
||||
@admin.register(Content)
|
||||
class ContentAdmin(admin.ModelAdmin):
|
||||
@admin.register(Filter)
|
||||
class FilterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"subscription",
|
||||
"item_id",
|
||||
"item_guid",
|
||||
"item_url",
|
||||
"item_title",
|
||||
"item_content_hash"
|
||||
"id", "name", "guild_id"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(TrackedContent)
|
||||
class TrackedContentAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"guid", "title", "url", "subscription", "blocked", "creation_datetime"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(SavedGuilds)
|
||||
class SavedGuildAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "name", "icon"
|
||||
]
|
||||
|
||||
|
||||
@admin.register(ArticleMutator)
|
||||
class ArticleMutatorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "name", "value"
|
||||
]
|
||||
|
||||
@admin.register(GuildSettings)
|
||||
class GuildSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "guild_id", "default_embed_colour", "active"
|
||||
]
|
||||
|
@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.0.4 on 2024-09-24 14:05
|
||||
# Generated by Django 5.0.4 on 2024-06-17 01:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -10,70 +11,53 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
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',
|
||||
name='Filter',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=32)),
|
||||
('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')),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'filter',
|
||||
'verbose_name_plural': 'filters',
|
||||
'get_latest_by': 'id',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BotLogicLogs',
|
||||
name='SavedGuilds',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('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')),
|
||||
('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?")),
|
||||
],
|
||||
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',
|
||||
@ -81,26 +65,76 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=32)),
|
||||
('url', models.URLField()),
|
||||
('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)),
|
||||
('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)),
|
||||
('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='Content',
|
||||
name='TrackedContent',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('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')),
|
||||
('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)),
|
||||
],
|
||||
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'),
|
||||
),
|
||||
]
|
||||
|
21
apps/home/migrations/0002_articlemutator.py
Normal file
21
apps/home/migrations/0002_articlemutator.py
Normal file
@ -0,0 +1,21 @@
|
||||
# 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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,47 +0,0 @@
|
||||
# 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)
|
||||
]
|
@ -1,67 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
37
apps/home/migrations/0003_initial_mutator_data.py
Normal file
37
apps/home/migrations/0003_initial_mutator_data.py
Normal file
@ -0,0 +1,37 @@
|
||||
# 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)
|
||||
]
|
@ -1,19 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
17
apps/home/migrations/0004_remove_subscription_uwuify.py
Normal file
17
apps/home/migrations/0004_remove_subscription_uwuify.py
Normal file
@ -0,0 +1,17 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
@ -1,26 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
18
apps/home/migrations/0005_subscription_mutators.py
Normal file
18
apps/home/migrations/0005_subscription_mutators.py
Normal file
@ -0,0 +1,18 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -1,22 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -1,20 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
@ -0,0 +1,27 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
@ -1,19 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -1,32 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
18
apps/home/migrations/0009_subscription_embed_colour.py
Normal file
18
apps/home/migrations/0009_subscription_embed_colour.py
Normal file
@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# 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(),
|
||||
),
|
||||
]
|
@ -1,19 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
@ -0,0 +1,34 @@
|
||||
# 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,18 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-10-11 16:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0010_discordchannel_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='server',
|
||||
name='is_bot_operational',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-10-11 16:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0011_server_is_bot_operational'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='is_bot_operational',
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
),
|
||||
]
|
@ -0,0 +1,34 @@
|
||||
# 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,18 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-10-14 22:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0012_alter_server_is_bot_operational'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='channels',
|
||||
field=models.ManyToManyField(related_name='subscriptions', to='home.discordchannel'),
|
||||
),
|
||||
]
|
19
apps/home/migrations/0013_subscription_published_theshold.py
Normal file
19
apps/home/migrations/0013_subscription_published_theshold.py
Normal file
@ -0,0 +1,19 @@
|
||||
# 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,23 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-10-14 23:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0013_alter_subscription_channels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='discordchannel',
|
||||
name='id',
|
||||
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='id',
|
||||
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
|
||||
),
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
@ -0,0 +1,67 @@
|
||||
# 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,19 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-10-15 18:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0014_alter_discordchannel_id_alter_server_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='message_style',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='home.messagestyle'),
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# 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,57 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-11-01 23:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0015_alter_subscription_message_style'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_author',
|
||||
field=models.CharField(default='', max_length=256),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_author_url',
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_description',
|
||||
field=models.CharField(default='', max_length=1024),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_feed_title',
|
||||
field=models.CharField(default='', max_length=1024),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_feed_url',
|
||||
field=models.URLField(default=''),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_image_url',
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_published',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='item_thumbnail_url',
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
]
|
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-11-01 23:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0016_content_item_author_content_item_author_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='blocked',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
19
apps/home/migrations/0017_trackedcontent_message_id.py
Normal file
19
apps/home/migrations/0017_trackedcontent_message_id.py
Normal file
@ -0,0 +1,19 @@
|
||||
# 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,18 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-11-01 23:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0017_content_blocked'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='content',
|
||||
name='item_author',
|
||||
field=models.CharField(blank=True, max_length=256, null=True),
|
||||
),
|
||||
]
|
19
apps/home/migrations/0018_subchannel_channel_name.py
Normal file
19
apps/home/migrations/0018_subchannel_channel_name.py
Normal file
@ -0,0 +1,19 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# 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,29 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-11-13 00:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0018_alter_content_item_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SubscriptionRecommendation',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=32)),
|
||||
('description', models.CharField(max_length=250)),
|
||||
('url', models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subscription',
|
||||
name='unique_rules',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='UniqueContentRule',
|
||||
),
|
||||
]
|
@ -1,47 +0,0 @@
|
||||
# Generated by Django 5.0.4 on 2024-11-13 00:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def add_subscription_recommendations(apps, schema_editor):
|
||||
model = apps.get_model("home", "SubscriptionRecommendation")
|
||||
model.objects.create(
|
||||
name="BBC News",
|
||||
description="BBC News - News Front Page.",
|
||||
url="http://feeds.bbci.co.uk/news/rss.xml"
|
||||
)
|
||||
model.objects.create(
|
||||
name="BBC News · Entertainment",
|
||||
description="BBC News - Culture.",
|
||||
url="https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml"
|
||||
)
|
||||
model.objects.create(
|
||||
name="BBC News · Technology",
|
||||
description="BBC News - Technology.",
|
||||
url="https://feeds.bbci.co.uk/news/technology/rss.xml"
|
||||
)
|
||||
model.objects.create(
|
||||
name="Sky News",
|
||||
description="The Latest News from the UK and Around the World.",
|
||||
url="https://feeds.skynews.com/feeds/rss/home.xml"
|
||||
)
|
||||
model.objects.create(
|
||||
name="Sky News · Strange News",
|
||||
description="Weird News - Strange and Odd News Stories.",
|
||||
url="https://feeds.skynews.com/feeds/rss/strange.xml"
|
||||
)
|
||||
model.objects.create(
|
||||
name="Free Games Finder",
|
||||
description="Free Games Finder's mission is to find and raise awareness of free games.",
|
||||
url="https://steamcommunity.com/groups/freegamesfinders/rss/"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0019_subscriptionrecommendation_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_subscription_recommendations)
|
||||
]
|
21
apps/home/migrations/0020_guildsettings.py
Normal file
21
apps/home/migrations/0020_guildsettings.py
Normal file
@ -0,0 +1,21 @@
|
||||
# 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -0,0 +1,122 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
22
apps/home/migrations/0022_populate_guild_settings.py
Normal file
22
apps/home/migrations/0022_populate_guild_settings.py
Normal file
@ -0,0 +1,22 @@
|
||||
# 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,55 +1,179 @@
|
||||
# -*- 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.exceptions import ValidationError
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Server
|
||||
|
||||
class Server(models.Model):
|
||||
class GuildSettings(models.Model):
|
||||
"""
|
||||
Represents a Discord Server.
|
||||
Instances of this model are automatically handled, and manual intervension
|
||||
should be avoided if possible.
|
||||
"""
|
||||
|
||||
id = models.PositiveBigIntegerField(primary_key=True)
|
||||
name = models.CharField(max_length=128)
|
||||
icon_hash = models.CharField(max_length=128, blank=True, null=True)
|
||||
is_bot_operational = models.BooleanField(default=None, null=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "server"
|
||||
verbose_name_plural = "servers"
|
||||
get_latest_by = "name"
|
||||
|
||||
@property
|
||||
def icon_url(self):
|
||||
return f"https://cdn.discordapp.com/icons/{self.id}/{self.icon_hash}.webp?size=80"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
# region Content Filter
|
||||
|
||||
class ContentFilter(models.Model):
|
||||
"""
|
||||
Filters for the content produced by Subscriptions.
|
||||
Owned by the related server.
|
||||
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.
|
||||
"""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
return GuildSettings.objects.get(guild_id=self.guild_id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
GuildSettings.objects.get_or_create(guild_id=self.guild_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SubChannel(models.Model):
|
||||
"""
|
||||
Represents a Discord TextChannel, saved against a Subscription.
|
||||
SubChannels are used as targets to send content from Subscriptions.
|
||||
"""
|
||||
|
||||
id = models.AutoField(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
|
||||
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
|
||||
@ -65,239 +189,196 @@ class ContentFilter(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")),
|
||||
)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "filter"
|
||||
verbose_name_plural = "filters"
|
||||
get_latest_by = "id"
|
||||
ordering = ("name",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "guild_id"],
|
||||
name="unique name & guild id pair"
|
||||
)
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
# region Message Mutator
|
||||
class TrackedContent(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.
|
||||
"""
|
||||
|
||||
class MessageMutator(models.Model):
|
||||
"""
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "message mutator"
|
||||
verbose_name_plural = "message mutators"
|
||||
get_latest_by = "id"
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
# region Message Style
|
||||
|
||||
class MessageStyle(models.Model):
|
||||
"""
|
||||
Custom styles to be applied via the Bot.
|
||||
Owned by the related server.
|
||||
"""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, null=True, blank=False)
|
||||
|
||||
name = models.CharField(max_length=32)
|
||||
colour = models.CharField(max_length=6, default="3498db")
|
||||
is_embed = models.BooleanField()
|
||||
is_hyperlinked = models.BooleanField() # title only
|
||||
show_author = models.BooleanField()
|
||||
show_timestamp = models.BooleanField()
|
||||
show_images = models.BooleanField()
|
||||
fetch_images = models.BooleanField() # if not included with RSS item
|
||||
|
||||
title_mutator = models.ForeignKey(
|
||||
to=MessageMutator,
|
||||
related_name="title_mutated_messagestyle",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
description_mutator = models.ForeignKey(
|
||||
to=MessageMutator,
|
||||
related_name="desc_mutated_messagestyle",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
auto_created = models.BooleanField(default=False, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "message style"
|
||||
verbose_name_plural = "message styles"
|
||||
get_latest_by = "id"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.auto_created:
|
||||
raise ValidationError("Cannot delete 'MessageStyle' instance with 'auto_created=True'")
|
||||
|
||||
# If this style is being used, reset the users to the default style
|
||||
default_message_style = MessageStyle.objects.get(server=self.server, auto_created=True)
|
||||
Subscription.objects \
|
||||
.filter(server=self.server, message_style=self) \
|
||||
.update(message_style=default_message_style)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@receiver(post_save, sender=Server)
|
||||
def create_default_items(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Create a default message style, so the user can get straight into creating subscriptions
|
||||
# (subscriptions require a message style to exist)
|
||||
MessageStyle.objects.create(
|
||||
server=instance,
|
||||
name=_("Default Message Style"),
|
||||
colour="3498db",
|
||||
is_embed=True,
|
||||
is_hyperlinked=True,
|
||||
show_author=True,
|
||||
show_timestamp=True,
|
||||
show_images=True,
|
||||
fetch_images=True,
|
||||
title_mutator=None,
|
||||
description_mutator=None,
|
||||
auto_created=True
|
||||
)
|
||||
|
||||
|
||||
# region Discord Channel
|
||||
|
||||
class DiscordChannel(models.Model):
|
||||
"""
|
||||
Store limited data on a relevant Channel from Discord, used to indicate
|
||||
where subscriptions should send content to. Instance creation & deletion
|
||||
is handled internally, when Subscriptions are modified.
|
||||
"""
|
||||
|
||||
id = models.PositiveBigIntegerField(primary_key=True)
|
||||
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
|
||||
name = models.CharField(max_length=128)
|
||||
is_nsfw = models.BooleanField()
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.name}"
|
||||
|
||||
|
||||
# region Subscription
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
These represent RSSFeeds, storing relevant settings for managing them.
|
||||
Owned by the related server.
|
||||
"""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
|
||||
|
||||
name = models.CharField(max_length=32, blank=False)
|
||||
url = models.URLField()
|
||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||
updated_at = models.DateTimeField(default=timezone.now)
|
||||
extra_notes = models.CharField(max_length=250, default="", blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
publish_threshold = models.DateTimeField(default=timezone.now)
|
||||
channels = models.ManyToManyField(to=DiscordChannel, related_name="subscriptions", blank=False)
|
||||
filters = models.ManyToManyField(to=ContentFilter, blank=True)
|
||||
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=False)
|
||||
|
||||
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_description = models.CharField(max_length=1024)
|
||||
item_content_hash = models.CharField(max_length=1024)
|
||||
item_image_url = models.URLField(null=True, blank=True)
|
||||
item_thumbnail_url = models.URLField(null=True, blank=True)
|
||||
item_published = models.DateField(null=True, blank=True)
|
||||
item_author = models.CharField(max_length=256, null=True, blank=True)
|
||||
item_author_url = models.URLField(null=True, blank=True)
|
||||
item_feed_title = models.CharField(max_length=1024)
|
||||
item_feed_url = models.URLField()
|
||||
|
||||
blocked = models.BooleanField(default=False)
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
# Subscription Recommendations
|
||||
|
||||
class SubscriptionRecommendation(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField(max_length=32)
|
||||
description = models.CharField(max_length=250)
|
||||
url = models.URLField()
|
||||
|
||||
def __str__(self):
|
||||
return self.url
|
||||
|
@ -1,91 +0,0 @@
|
||||
|
||||
const validateBootstrapStyle = style => {
|
||||
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
|
||||
throw new Error(`${style} is not a valid style`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
const arrayToHtmlList = (array, bold=false) => {
|
||||
$ul = $("<ul>").addClass("mb-0");
|
||||
|
||||
array.forEach(item => {
|
||||
let $li = $("<li>");
|
||||
$ul.append(bold ? $li.append($("<b>").text(item)) : $li.text(item));
|
||||
});
|
||||
|
||||
return $ul;
|
||||
}
|
||||
|
||||
|
||||
const createModal = async (options = {}) => {
|
||||
let $modal = $("#customModal");
|
||||
|
||||
// If transitioning from one modal to another, wait for this one to close
|
||||
if ($modal.is(':visible')) {
|
||||
$modal.modal('hide');
|
||||
await new Promise(resolve => $modal.one('hidden.bs.modal', resolve));
|
||||
}
|
||||
|
||||
let defaults = {
|
||||
title: "Modal Title",
|
||||
texts: [
|
||||
{
|
||||
content: "This is a modal",
|
||||
html: false
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: "Okay",
|
||||
className: "btn-primary",
|
||||
iconClass: "",
|
||||
tabIndex: 2,
|
||||
closeModal: true,
|
||||
onClick: null
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
let settings = { ...defaults, ...options };
|
||||
|
||||
$modal.find(".modal-title").text(settings.title);
|
||||
|
||||
// Texts
|
||||
let $body = $modal.find(".modal-body").empty();
|
||||
|
||||
settings.texts.forEach((text, idx) => {
|
||||
if (text.html) {
|
||||
$body.append(text.content);
|
||||
return;
|
||||
}
|
||||
|
||||
let $para = $("<p>", {
|
||||
text: text.content,
|
||||
class: idx + 1 === settings.texts.length ? "mb-0" : ""
|
||||
});
|
||||
$body.append($para);
|
||||
});
|
||||
|
||||
// Buttons
|
||||
let $footer = $modal.find(".modal-footer").empty();
|
||||
|
||||
settings.buttons.forEach(button => {
|
||||
let $btn = $("<button>", {
|
||||
type: "button",
|
||||
class: "btn rounded-1 " + button.className,
|
||||
tabIndex: button.tabIndex,
|
||||
html: button.iconClass ?`<i class="${button.iconClass} ${button.text ? "me-2" : ""}"></i>${button.text ? button.text : ""}` : button.text
|
||||
});
|
||||
|
||||
$btn.on("click", async () => {
|
||||
if (button.onClick) await button.onClick();
|
||||
if (button.closeModal) $modal.modal("hide");
|
||||
});
|
||||
|
||||
$footer.append($btn);
|
||||
});
|
||||
|
||||
$modal.modal("show");
|
||||
}
|
@ -1,423 +0,0 @@
|
||||
|
||||
// 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return server[0];
|
||||
}
|
||||
|
||||
function addToLoadedServers(serverData, autoSelect=false) {
|
||||
_loadedServers.push(serverData);
|
||||
createSelectButton(serverData);
|
||||
|
||||
if (autoSelect) {
|
||||
selectServer(serverData["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromLoadedServers(id) {
|
||||
_loadedServers = _loadedServers.filter(item => item.id !== id);
|
||||
removeSelectButton(id)
|
||||
|
||||
if (selectedServer.id === id) {
|
||||
selectedServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// region Loaded Channels
|
||||
|
||||
var _loadedChannels = {};
|
||||
async function loadedChannels(serverId) {
|
||||
if (!(serverId in _loadedChannels)) {
|
||||
await fetchChannels(serverId);
|
||||
}
|
||||
|
||||
return _loadedChannels[serverId]
|
||||
}
|
||||
|
||||
$(document).on("selectedServerChange", async function() {
|
||||
// Try load channels to determine if bot has permissions
|
||||
loadedChannels(selectedServer.id);
|
||||
});
|
||||
|
||||
const fetchChannels = async serverId => {
|
||||
$(".sidebar .sidebar-item").prop("disabled", true);
|
||||
|
||||
try {
|
||||
channels = await ajaxRequest(`/generate-channels?guild=${serverId}`, "GET");
|
||||
_loadedChannels[serverId] = channels;
|
||||
unspotItem(serverId); // remove the spot because no errors occured
|
||||
}
|
||||
catch (error) {
|
||||
logError(error);
|
||||
unspotItem(serverId);
|
||||
|
||||
switch (error?.status) {
|
||||
case 429:
|
||||
rateLimitedLoadingChannels(error.responseJSON.retry_after);
|
||||
break;
|
||||
|
||||
case 403:
|
||||
notAuthorisedLoadingChannels(serverId);
|
||||
break;
|
||||
|
||||
default:
|
||||
alert("unknown error loading channels");
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
finally {
|
||||
$(".sidebar .sidebar-item").prop("disabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
const rateLimitedLoadingChannels = retryAfterSeconds => {
|
||||
createModal({
|
||||
title: "Failed to Fetch Server Channels",
|
||||
texts: [
|
||||
{ content: "Discord is rate-limiting your request." },
|
||||
{ content: `This happens when making requests too quickly. Retry after ${retryAfterSeconds} seconds to continue without issue.` }
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-warning px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const notAuthorisedLoadingChannels = serverId => {
|
||||
// Mark the sidebar item as non-operational
|
||||
spotItem(serverId, "danger");
|
||||
|
||||
const inviteBotToServer = () => {
|
||||
window.open(
|
||||
`https://discord.com/oauth2/authorize
|
||||
?client_id=${discordClientId}
|
||||
&permissions=2147534848
|
||||
&scope=bot+applications.commands
|
||||
&guild_id=${serverId}
|
||||
&disable_guild_select=true`,
|
||||
"_blank"
|
||||
);
|
||||
}
|
||||
|
||||
// Inform the user auth problem
|
||||
createModal({
|
||||
title: "Failed to Fetch Server Channels",
|
||||
texts: [
|
||||
{ content: "The Discord Bot is unable to access this server's channels, certain features will not work." },
|
||||
{ content: "Ensure the Bot is a member, and has the neccessary permissions to operate." }
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: "Invite the Bot",
|
||||
className: "btn-primary me-3",
|
||||
iconClass: "bi-envelope-plus",
|
||||
closeModal: true,
|
||||
onClick: inviteBotToServer
|
||||
},
|
||||
{
|
||||
className: "btn-secondary",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// region UI Buttons
|
||||
|
||||
function createSelectButton(serverData) {
|
||||
// server details
|
||||
const id = serverData["id"];
|
||||
const name = serverData["name"];
|
||||
const iconHash = serverData["icon_hash"];
|
||||
const isBotOperational = serverData["is_bot_operational"];
|
||||
|
||||
let template = $($("#serverItemTemplate").html());
|
||||
const imageUrl = `https://cdn.discordapp.com/icons/${id}/${iconHash}.webp?size=80`;
|
||||
const altText = name.split(' ').map(word => word.charAt(0)).join(''); // initials of server name, used if iconUrl is 404
|
||||
|
||||
template.find(".js-image").attr("src", imageUrl).attr("alt", altText);
|
||||
template.find(".js-name").text(name);
|
||||
template.find(".js-id").text(id);
|
||||
template.find(".sidebar-item").attr("data-id", id);
|
||||
|
||||
// Show inoperational status, also, `isBotOperatioanl` can be null,
|
||||
// so we can't rely on it's truthy value.
|
||||
if (isBotOperational === false) {
|
||||
template.find(".sidebar-item").addClass("spot spot-danger");
|
||||
}
|
||||
|
||||
// Bind the button for selecting this server
|
||||
template.find(".sidebar-item").off("click").on("click", function() {
|
||||
const myID = $(this).data("id");
|
||||
|
||||
// only select if not already selected, otherwise hide sidebar on smaller screens (responsive)
|
||||
selectedServer?.id !== myID ? selectServer(myID) : setSidebarVisibility(false);
|
||||
});
|
||||
|
||||
$("#serverList").prepend(template);
|
||||
}
|
||||
|
||||
function removeSelectButton(id) {
|
||||
$(`#serverList .server-item[data-id=${id}]`).remove();
|
||||
}
|
||||
|
||||
$("#backToSelectServer").on("click", function() {
|
||||
$("#noSelectedServer").show();
|
||||
$("#selectedServerContainer").hide();
|
||||
$("#serverList .server-item > .server-item-selector.active").removeClass("active");
|
||||
selectedServer = null;
|
||||
});
|
||||
|
||||
|
||||
// #region Server Selection
|
||||
|
||||
function selectServer(id) {
|
||||
let server = getServerFromSnowflake(id);
|
||||
|
||||
// Change appearance of selected vs none-selected items
|
||||
$("#serverList .sidebar-item").removeClass("active");
|
||||
$(`#serverList .sidebar-item[data-id="${id}"]`).addClass("active");
|
||||
|
||||
// Global variable
|
||||
selectedServer = server;
|
||||
|
||||
// Close sidebar on smaller screens
|
||||
setSidebarVisibility(false);
|
||||
|
||||
// Show no server selected if that's the case
|
||||
if (!server) {
|
||||
$("#noSelectedServer").show();
|
||||
$("#selectedServerContainer").hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
$("#noSelectedServer").hide();
|
||||
$("#selectedServerContainer").show().css("display", "flex");
|
||||
|
||||
// Announce change to any listeners
|
||||
$(document).trigger("selectedServerChange");
|
||||
|
||||
}
|
||||
|
||||
|
||||
// #region Resolve Strings
|
||||
|
||||
function resolveServerStrings() {
|
||||
// Server icon
|
||||
$(".resolve-to-server-icon").attr(
|
||||
"src",
|
||||
`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon_hash}.webp?size=80`
|
||||
).attr("alt", selectedServer.name.split(' ').map(word => word.charAt(0)).join(''));
|
||||
|
||||
// Server names
|
||||
$(".resolve-to-server-name").text(selectedServer.name);
|
||||
|
||||
// Server Guild Ids
|
||||
$(".resolve-to-server-id").text(selectedServer.id);
|
||||
|
||||
// Bot Invite links
|
||||
$(".resolve-to-invite-link").attr("href", `https://discord.com/oauth2/authorize
|
||||
?client_id=${discordClientId}
|
||||
&permissions=2147534848
|
||||
&scope=bot+applications.commands
|
||||
&guild_id=${selectedServer.id}
|
||||
&disable_guild_select=true`);
|
||||
}
|
||||
|
||||
|
||||
// region Change Listener
|
||||
|
||||
$(document).on("selectedServerChange", async function() {
|
||||
resolveServerStrings();
|
||||
$("#serverJoinAlert").hide();
|
||||
});
|
||||
|
||||
|
||||
// region Load Servers
|
||||
|
||||
async function loadServers(generate=true) {
|
||||
|
||||
// Remove any previously loaded servers
|
||||
$(".sidebar .sidebar-item").closest("li").remove();
|
||||
|
||||
// Show placeholder items & hide rate limit warning
|
||||
$(".sidebar .sidebar-loading").show();
|
||||
$(".sidebar .server-rate-limit").hide();
|
||||
|
||||
try {
|
||||
let data = await ajaxRequest(
|
||||
generate ? "/generate-servers/" : "/api/servers/",
|
||||
"GET"
|
||||
);
|
||||
data = generate ? data : data.results; // api responds differently
|
||||
data.forEach(server => addToLoadedServers(server, false));
|
||||
}
|
||||
catch (error) {
|
||||
switch (error?.status) {
|
||||
case 401:
|
||||
window.location.href = "/login"; // discord token has expired
|
||||
break;
|
||||
case 429:
|
||||
$(".sidebar .server-rate-limit").show();
|
||||
break;
|
||||
default:
|
||||
logError(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$(".sidebar .sidebar-loading").hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Retry load servers button
|
||||
$(".sidebar .sidebar-retry-btn").on("click", loadServers);
|
||||
|
||||
|
||||
// region Spot Icons
|
||||
|
||||
const unspotItem = id => {
|
||||
$(`.sidebar .sidebar-item[data-id="${id}"]`).removeClass(
|
||||
"spot spot-primary spot-secondary spot-success spot-info spot-warning spot-danger"
|
||||
);
|
||||
}
|
||||
|
||||
const spotItem = (id, spotStyle) => {
|
||||
$(`.sidebar .sidebar-item[data-id="${id}"]`).addClass(`spot spot-${spotStyle}`);
|
||||
}
|
||||
|
||||
|
||||
// region View Other Users
|
||||
|
||||
$(".js-serverUsersBtn").on("click", () => {
|
||||
createModal({
|
||||
title: "Other Users",
|
||||
texts: [
|
||||
{content: "This feature has not yet been implemented."}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region View Edit History
|
||||
|
||||
$(".js-serverHistoryBtn").on("click", () => {
|
||||
createModal({
|
||||
title: "Edit History",
|
||||
texts: [
|
||||
{content: "This feature has not yet been implemented."}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region Delete Server Data
|
||||
|
||||
const eraseServerData = async server => {
|
||||
const response = await ajaxRequest(`/api/servers/${server.id}/`, "DELETE");
|
||||
|
||||
// Only server owners can delete all data at once
|
||||
if (response?.status === 403) {
|
||||
createModal({
|
||||
title: `Failed to Delete Data for ${server.name}`,
|
||||
texts: [{
|
||||
content: `Only the owner of <b>${server.name}</b> can erase all of it's data.`,
|
||||
html: true
|
||||
}],
|
||||
buttons: [{
|
||||
className: "btn-danger px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return to the 'select a server' screen
|
||||
selectServer(null);
|
||||
|
||||
// Refresh the sidebar server list
|
||||
await loadServers(false);
|
||||
}
|
||||
|
||||
$(".js-closeServerBtn").on("click", () => {
|
||||
selectServer(null);
|
||||
});
|
||||
|
||||
$(".js-eraseServerBtn").on("click", () => {
|
||||
const server = selectedServer; // Store incase it changes
|
||||
const itemsToLose = arrayToHtmlList([
|
||||
"Subscriptions",
|
||||
"Filters",
|
||||
"Message Styles",
|
||||
"Tracked Content"
|
||||
]).addClass("mb-3").prop("outerHTML");
|
||||
|
||||
createModal({
|
||||
title: `Delete Data for ${server.name}?`,
|
||||
texts: [
|
||||
{content: "You will lose all data related to this server, including:"},
|
||||
{content: itemsToLose, html: true},
|
||||
{content: "Please reconsider this decision."}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: () => eraseServerData(server)
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
@ -1,623 +0,0 @@
|
||||
|
||||
// region Init Table
|
||||
|
||||
function initializeDataTable(tableId, columns) {
|
||||
$(tableId).DataTable({
|
||||
info: false,
|
||||
paging: false,
|
||||
ordering: false,
|
||||
searching: false,
|
||||
autoWidth: false,
|
||||
order: [],
|
||||
language: {
|
||||
emptyTable: "No results found"
|
||||
},
|
||||
select: {
|
||||
style: "multi+shift",
|
||||
selector: 'th:first-child input[type="checkbox"]'
|
||||
},
|
||||
columnDefs: [
|
||||
{ orderable: false, targets: "no-sort" },
|
||||
{
|
||||
targets: 0,
|
||||
checkboxes: { selectRow: true }
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
// Select row checkbox column
|
||||
title: '<input type="checkbox" class="form-check-input table-select-all" />',
|
||||
data: null,
|
||||
orderable: false,
|
||||
className: "col-checkbox text-center",
|
||||
render: function() {
|
||||
return '<input type="checkbox" class="form-check-input table-select-row" />'
|
||||
}
|
||||
},
|
||||
{ data: "id", visible: false },
|
||||
...columns
|
||||
]
|
||||
});
|
||||
bindTablePagination(tableId);
|
||||
bindTablePageSizer(tableId);
|
||||
bindTableSearch(tableId);
|
||||
bindRefreshButton(tableId);
|
||||
bindTableSelectColumn(tableId);
|
||||
}
|
||||
|
||||
|
||||
// region Filters & Ordering
|
||||
|
||||
// Filter methods
|
||||
var _tableFilters = {};
|
||||
function getTableFilters(tableId) {
|
||||
return _tableFilters[tableId];
|
||||
}
|
||||
|
||||
function setTableFilter(tableId, key, value) {
|
||||
if (!_tableFilters[tableId]) {
|
||||
_tableFilters[tableId] = {};
|
||||
}
|
||||
|
||||
_tableFilters[tableId][key] = value;
|
||||
}
|
||||
|
||||
// Sort methods
|
||||
var _tableOrdering = {};
|
||||
function getTableOrdering(tableId) {
|
||||
return _tableOrdering[tableId];
|
||||
}
|
||||
|
||||
function setTableOrdering(tableId, value) {
|
||||
_tableOrdering[tableId] = value;
|
||||
}
|
||||
|
||||
// Clear all kinds of sorting and filtering when changing servers
|
||||
$(document).on("selectedServerChange", function() {
|
||||
_tableFilters = {};
|
||||
_tableOrdering = {};
|
||||
$(".table-search-input").val("");
|
||||
});
|
||||
|
||||
|
||||
// region Load & Clear Data
|
||||
|
||||
function wipeTable(tableId) {
|
||||
$(`${tableId} thead .table-select-all`).prop("checked", false).prop("indeterminate", false);
|
||||
$(tableId).DataTable().clear().draw(false)
|
||||
}
|
||||
|
||||
function populateTable(tableId, data) {
|
||||
$(tableId).DataTable().rows.add(data.results).draw(false);
|
||||
updateTablePagination(tableId, data);
|
||||
updateTableTotalCount(tableId, data);
|
||||
}
|
||||
|
||||
async function loadTableData(tableId, url, method) {
|
||||
fixTablePagination(tableId);
|
||||
disableTableControls(tableId);
|
||||
|
||||
// Create querystring for filtering against the API
|
||||
const filters = getTableFilters(tableId);
|
||||
const ordering = getTableOrdering(tableId);
|
||||
const querystring = makeQuerystring(filters, ordering);
|
||||
|
||||
// API request
|
||||
const data = await ajaxRequest(url + querystring, method);
|
||||
|
||||
// Update table with new data
|
||||
wipeTable(tableId);
|
||||
populateTable(tableId, data);
|
||||
enableTableControls(tableId);
|
||||
}
|
||||
|
||||
|
||||
// region Pagination
|
||||
|
||||
function bindTablePagination(tableId) {
|
||||
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
|
||||
$paginationArea.on("click", ".page-link", function() {
|
||||
let currentPage = parseInt($paginationArea.data("page"));
|
||||
let wantedPage;
|
||||
|
||||
if ($(this).hasClass("page-prev")) {
|
||||
wantedPage = currentPage - 1;
|
||||
}
|
||||
else if ($(this).hasClass("page-next")) {
|
||||
wantedPage = currentPage + 1;
|
||||
}
|
||||
else {
|
||||
wantedPage = $(this).attr("data-page")
|
||||
}
|
||||
|
||||
setTableFilter(tableId, "page", wantedPage);
|
||||
$(tableId).trigger("doDataLoad");
|
||||
});
|
||||
}
|
||||
|
||||
function fixTablePagination(tableId) {
|
||||
let filters = getTableFilters(tableId);
|
||||
let $pageSizer = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find(".table-page-sizer");
|
||||
|
||||
if (!("page" in filters)) {
|
||||
setTableFilter(tableId, "page", 1);
|
||||
}
|
||||
if (!("page_size" in filters)) {
|
||||
setTableFilter(tableId, "page_size", $($pageSizer).val())
|
||||
}
|
||||
}
|
||||
|
||||
function updateTablePagination(tableId, data) {
|
||||
let filters = getTableFilters(tableId);
|
||||
|
||||
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
|
||||
$paginationArea.data("page", filters.page); // store the page for later
|
||||
|
||||
// Remove existing buttons for specific pages
|
||||
$paginationArea.find(".page-pick").remove();
|
||||
|
||||
// Enable/disable 'next' and 'prev' buttons
|
||||
$paginationArea.find(".page-prev").toggleClass("disabled", !data.previous);
|
||||
$paginationArea.find(".page-next").toggleClass("disabled", !data.next);
|
||||
|
||||
const pagesToShow = Math.max(Math.ceil(data.count / filters.page_size), 1);
|
||||
const maxPagesToShow = 10;
|
||||
|
||||
let startPage = 1;
|
||||
let endPage;
|
||||
let page = parseInt(filters.page);
|
||||
|
||||
// Determine the start and end page
|
||||
if (pagesToShow <= maxPagesToShow) {
|
||||
endPage = pagesToShow;
|
||||
}
|
||||
else {
|
||||
const halfVisible = Math.floor(maxPagesToShow / 2);
|
||||
if (page <= halfVisible) {
|
||||
endPage = maxPagesToShow;
|
||||
}
|
||||
else if (page + halfVisible >= pagesToShow) {
|
||||
startPage = pagesToShow - maxPagesToShow + 1;
|
||||
endPage = pagesToShow;
|
||||
}
|
||||
else {
|
||||
startPage = page - halfVisible;
|
||||
endPage = page + halfVisible;
|
||||
}
|
||||
}
|
||||
|
||||
// Add buttons
|
||||
if (startPage > 1) {
|
||||
AddTablePageButton($paginationArea, 1);
|
||||
if (startPage > 2) {
|
||||
AddTablePageEllipsis($paginationArea);
|
||||
}
|
||||
}
|
||||
for (i = startPage; i <= endPage; i++) {
|
||||
AddTablePageButton($paginationArea, i, page);
|
||||
}
|
||||
if (endPage < pagesToShow) {
|
||||
if (endPage < pagesToShow - 1) {
|
||||
AddTablePageEllipsis($paginationArea);
|
||||
}
|
||||
AddTablePageButton($paginationArea, pagesToShow);
|
||||
}
|
||||
}
|
||||
|
||||
function AddTablePageButton($paginationArea, number, currentPage) {
|
||||
let pageItem = $("<li>").addClass("page-item");
|
||||
let pageLink = $("<button>")
|
||||
.attr("type", "button")
|
||||
.attr("data-page", number)
|
||||
.addClass("page-link page-pick")
|
||||
.text(number);
|
||||
|
||||
if (number === parseInt(currentPage)) {
|
||||
pageLink.addClass("disabled").attr("tabindex", -1);
|
||||
}
|
||||
|
||||
pageItem.append(pageLink);
|
||||
$paginationArea.find(".page-next").parent().before(pageItem);
|
||||
}
|
||||
|
||||
function AddTablePageEllipsis($paginationArea) {
|
||||
let ellipsisItem = $("<li>").addClass("page-item disabled");
|
||||
let ellipsisLink = $("<span>").addClass("page-link page-pick").text("...");
|
||||
ellipsisItem.append(ellipsisLink);
|
||||
$paginationArea.find(".page-next").parent().before(ellipsisItem);
|
||||
}
|
||||
|
||||
|
||||
// region Page Sizer
|
||||
|
||||
function updateTableTotalCount(tableId, data) {
|
||||
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
|
||||
$tableControls.find('.pageinfo-total').text(data.count);
|
||||
}
|
||||
|
||||
function bindTablePageSizer(tableId) {
|
||||
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
|
||||
$tableControls.on("change", ".table-page-sizer", function() {
|
||||
setTableFilter(tableId, "page", "1");
|
||||
setTableFilter(tableId, "page_size", $(this).val());
|
||||
$(tableId).trigger("doDataLoad");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// region Search Filters
|
||||
|
||||
_searchTimeouts = {};
|
||||
function bindTableSearch(tableId) {
|
||||
const $tableFilters = $(tableId).closest('.js-tableBody').siblings('.js-tableFilters');
|
||||
$tableFilters.on("input", ".table-search-input", function() {
|
||||
$(this).data("was-focused", true);
|
||||
clearTimeout(_searchTimeouts[tableId]);
|
||||
|
||||
const searchString = $(this).val();
|
||||
setTableFilter(tableId, "search", searchString);
|
||||
setTableFilter(tableId, "page", 1); // back to first page, as desired page no. might not exist after filtering
|
||||
|
||||
_searchTimeouts[tableId] = setTimeout(function() {
|
||||
$(tableId).trigger("doDataLoad");
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// region Button Controls
|
||||
|
||||
function bindRefreshButton(tableId) {
|
||||
$controls = getTableFiltersComponent(tableId);
|
||||
$controls.on("click", ".js-tableRefreshBtn", function() {
|
||||
$controls.find(".js-tableDeleteBtn").prop("disabled", true);
|
||||
$controls.find(".js-tableShareBtn").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);
|
||||
|
||||
const selectionExists = doCheck || doIndeterminate;
|
||||
const $controls = getTableFiltersComponent(tableId);
|
||||
$controls.find(".js-tableShareBtn").prop("disabled", !selectionExists);
|
||||
$controls.find(".js-tableDeleteBtn").prop("disabled", !selectionExists);
|
||||
}
|
||||
|
||||
|
||||
// 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(".js-tableControls");
|
||||
|
||||
$tableBody.find(".disable-while-loading").prop("disabled", disabled);
|
||||
$tableFilters.find(".disable-while-loading").prop("disabled", disabled);
|
||||
$tableControls.find(".disable-while-loading").prop("disabled", disabled);
|
||||
|
||||
// Re-focus search bar if used
|
||||
const $search = $tableFilters.find(".disable-while-loading[type=search]");
|
||||
$search.data("was-focused") ? $search.focus() : null;
|
||||
}
|
||||
|
||||
|
||||
// region Modals
|
||||
|
||||
|
||||
async function openDataModal(modalId, pk, url) {
|
||||
$modal = $(modalId);
|
||||
$modal.data("primary-key", pk);
|
||||
clearValidation($modal);
|
||||
|
||||
if (parseInt(pk) === -1) {
|
||||
$modal.find(".form-create").show();
|
||||
$modal.find(".form-edit").hide();
|
||||
setDefaultModalData($modal);
|
||||
}
|
||||
else {
|
||||
$modal.find(".form-create").hide();
|
||||
$modal.find(".form-edit").show();
|
||||
await loadModalData($modal, url);
|
||||
}
|
||||
|
||||
$modal.modal("show");
|
||||
}
|
||||
|
||||
function setDefaultModalData($modal) {
|
||||
$modal.find("[data-field]").each(function() {
|
||||
const type = $(this).attr("type");
|
||||
const defaultVal = $(this).attr("data-default") || "";
|
||||
|
||||
if (type === "checkbox") {
|
||||
$(this).prop("checked", defaultVal === "true");
|
||||
}
|
||||
else if (type === "datetime-local") {
|
||||
$(this).val(getCurrentDateTime());
|
||||
}
|
||||
else if ($(this).is("select") && defaultVal === "firstOption") {
|
||||
$(this).val($(this).find("option:first").val()).change();
|
||||
}
|
||||
else {
|
||||
$(this).val(defaultVal).change();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearValidation($modal) {
|
||||
$modal.find(".invalid-feedback").remove();
|
||||
$modal.find(".is-invalid").removeClass("is-invalid");
|
||||
}
|
||||
|
||||
async function loadModalData($modal, url) {
|
||||
const data = await ajaxRequest(url, "GET");
|
||||
|
||||
$modal.find("[data-field]").each(function() {
|
||||
const key = $(this).attr("data-field");
|
||||
const value = data[key];
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
$(this).prop("checked", value);
|
||||
}
|
||||
else if (isISODateTimeString(value)) {
|
||||
$(this).val(value.split('+')[0].substring(0, 16));
|
||||
}
|
||||
else if ($(this).attr("type") === "color") {
|
||||
$(this).val(`#${value}`);
|
||||
}
|
||||
else {
|
||||
$(this).val(value).change();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onModalSubmit($modal, $table, url) {
|
||||
if (!selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearValidation($modal);
|
||||
let data = { server: selectedServer.id };
|
||||
|
||||
$modal.find("[data-field]").each(function() {
|
||||
const type = $(this).attr("type");
|
||||
const key = $(this).attr("data-field");
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
switch (type) {
|
||||
case "checkbox":
|
||||
value = $(this).prop("checked");
|
||||
break;
|
||||
case "color":
|
||||
value = $(this).val();
|
||||
value = value ? value.replace("#", "") : value;
|
||||
break;
|
||||
default:
|
||||
value = $(this).val();
|
||||
break;
|
||||
}
|
||||
|
||||
data[key] = value;
|
||||
});
|
||||
|
||||
const formData = objectToFormData(data);
|
||||
const id = $modal.data("primary-key");
|
||||
const isNewItem = parseInt(id) !== -1;
|
||||
const method = isNewItem ? "PUT" : "POST";
|
||||
url = isNewItem ? url + `${id}/` : url;
|
||||
|
||||
ajaxRequest(url, method, formData)
|
||||
.then(response => {
|
||||
$table.trigger("doDataLoad");
|
||||
$modal.modal("hide");
|
||||
})
|
||||
.catch(error => {
|
||||
logError(error);
|
||||
if (typeof error === "object" && "responseJSON" in error) {
|
||||
renderErrorMessages($modal, error.responseJSON);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// region Modal Error Msgs
|
||||
|
||||
function renderErrorMessages($modal, errorObj) {
|
||||
for (const key in errorObj) {
|
||||
const value = errorObj[key];
|
||||
const $input = $modal.find(`[data-field="${key}"]`);
|
||||
$input.addClass("is-invalid");
|
||||
$input.nextAll(".form-text").last().after(
|
||||
`<div class="invalid-feedback">${value}</div>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// region Table Col Types
|
||||
|
||||
function renderEditColumn(data) {
|
||||
const name = sanitise(data);
|
||||
return `<span class="act-as-link edit-modal" role="button">${name}</span>`
|
||||
}
|
||||
|
||||
function renderAnchorColumn(name, href) {
|
||||
name = sanitise(name);
|
||||
href = sanitise(href);
|
||||
|
||||
return `<a href="${href}" class="act-as-link">${name}</a>`;
|
||||
}
|
||||
|
||||
function renderBooleanColumn(data) {
|
||||
const iconClass = data ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger";
|
||||
return `<i class="bi ${iconClass}"></i>`;
|
||||
}
|
||||
|
||||
function renderBadgeColumn(data, colour=null) {
|
||||
let badge = $(`<span class="badge text-bg-secondary rounded-1 border border-1">${data}</span>`)
|
||||
if (colour) {
|
||||
badge[0].style.setProperty("border-color", `#${colour}`, "important");
|
||||
}
|
||||
|
||||
return badge.prop("outerHTML");
|
||||
}
|
||||
|
||||
function renderArrayBadgesColumn(data) {
|
||||
let badges = $("<div>");
|
||||
|
||||
data.forEach((item, index) => {
|
||||
let badge = $(`<span class="badge text-bg-secondary rounded-1">${item}</span>`);
|
||||
if (index > 0) { badge.addClass("ms-2") }
|
||||
badges.append(badge);
|
||||
});
|
||||
|
||||
return badges.html();
|
||||
}
|
||||
|
||||
function renderArrayDropdownColumn(data, icon, headerText) {
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let $dropdown = $(`
|
||||
<div class="dropdown">
|
||||
<button type="button" class="dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fs-5 bi ${icon}"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<li><h6 class="dropdown-header">${headerText} (${data.length})</h6></li>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
let $dropdownItems = $dropdown.find(".dropdown-menu")
|
||||
|
||||
data.forEach(item => {
|
||||
let $itemBtn = $(`<li><button type="button" class="dropdown-item">${item}</button></li>`);
|
||||
$dropdownItems.append($itemBtn);
|
||||
});
|
||||
|
||||
return $dropdown.prop("outerHTML");
|
||||
}
|
||||
|
||||
const renderHexColourColumn = data => {
|
||||
const hexWithHashtag = `#${data}`.toUpperCase();
|
||||
|
||||
let icon = $("<div>");
|
||||
icon.addClass("col-hex-icon");
|
||||
icon.css("background-color", hexWithHashtag);
|
||||
|
||||
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}">${icon.prop("outerHTML")}</span>`).tooltip()[0];
|
||||
}
|
||||
|
||||
function renderMutatorColumn(data) {
|
||||
if (!("id" in data)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return $(`<span class="badge text-bg-secondary rounded-1">${data.name}</span>`).prop("outerHTML");
|
||||
}
|
||||
|
||||
const renderLinkToStyleColumn = style => {
|
||||
const hexWithHashtag = `#${style.colour}`.toUpperCase();
|
||||
|
||||
let icon = $("<div>");
|
||||
icon.addClass("col-hex-icon js-openSubStyle");
|
||||
icon.css("background-color", hexWithHashtag);
|
||||
|
||||
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}" role="button">${icon.prop("outerHTML")}</span>`).tooltip()[0];
|
||||
}
|
||||
|
||||
const renderPopoverBadgesColumn = (items, iconClass) => {
|
||||
if (!items.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let $span = $("<span>");
|
||||
$span.attr("data-bs-toggle", "popover");
|
||||
$span.attr("data-bs-trigger", "hover focus");
|
||||
$span.attr("data-bs-custom-class", "table-badge-popover")
|
||||
$span.attr("data-bs-html", "true");
|
||||
|
||||
let $placeholderContainer = $("<div>");
|
||||
|
||||
items.forEach(item => {
|
||||
let $badge = $("<div>");
|
||||
$badge.addClass("badge text-bg-secondary rounded-1 me-2 mb-2 text-wrap mw-100 text-start");
|
||||
$badge.text(item);
|
||||
$placeholderContainer.append($badge);
|
||||
});
|
||||
|
||||
$span.attr("data-bs-content", $placeholderContainer.html());
|
||||
$span.html(`<i class="bi ${iconClass} fs-5"></i>`)
|
||||
return $span.popover()[0];
|
||||
}
|
||||
|
||||
const renderLinkToSubscription = subscriptionId => {
|
||||
const subTable = $(subTableId).DataTable()
|
||||
const row = subTable.row({id: subscriptionId});
|
||||
const name = row.data().name;
|
||||
return `<span class="act-as-link js-openContentSub" role="button">${name}</span>`
|
||||
}
|
||||
|
||||
|
||||
// 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();
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
const contentTableId = "#contentTable";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initContentModule() {
|
||||
initializeDataTable(
|
||||
contentTableId,
|
||||
[
|
||||
{
|
||||
title: "Subscription",
|
||||
data: "subscription",
|
||||
className: "col-1",
|
||||
render: () => renderLinkToSubscription()
|
||||
},
|
||||
{
|
||||
title: "Item ID",
|
||||
data: "item_id",
|
||||
className: "col-2"
|
||||
},
|
||||
{
|
||||
title: "Item GUID",
|
||||
data: "item_guid",
|
||||
className: "col-2"
|
||||
},
|
||||
{
|
||||
title: "Title",
|
||||
data: "item_title",
|
||||
className: "col-2"
|
||||
},
|
||||
{
|
||||
title: "URL",
|
||||
data: "item_url",
|
||||
className: "col-2"
|
||||
},
|
||||
{
|
||||
title: "Content Hash",
|
||||
data: "item_content_hash",
|
||||
className: "col-2"
|
||||
},
|
||||
{
|
||||
title: "Blocked",
|
||||
data: "blocked",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// region Delete Data
|
||||
|
||||
getTableFiltersComponent(contentTableId).find(".js-tableDeleteBtn").on("click", async function() {
|
||||
const rows = getSelectedTableRows(contentTableId);
|
||||
const names = rows.map(row => row.item_title)
|
||||
const isMany = names.length > 1;
|
||||
|
||||
const deleteContent = () => {
|
||||
rows.forEach(async row => {
|
||||
await ajaxRequest(`/api/content/${row.id}/`, "DELETE");
|
||||
})
|
||||
|
||||
setTimeout(() => { $(contentTableId).trigger("doDataLoad") }, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `Delete ${isMany ? "Many Contents" : "Content"}`,
|
||||
texts: [
|
||||
{
|
||||
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "this"} content${isMany ? "s" : ""}?</p>`,
|
||||
html: true
|
||||
},
|
||||
{
|
||||
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||
html: true
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteContent
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region Open Content Sub
|
||||
|
||||
$(contentTableId).on("click", ".js-openContentSub", async event => {
|
||||
const contentTable = $(contentTableId).DataTable();
|
||||
const row = contentTable.row($(event.currentTarget).closest("tr"));
|
||||
const subscriptionId = row.data().subscription;
|
||||
|
||||
$("#subscriptionsTab").click();
|
||||
await openDataModal(subModalId, subscriptionId, `/api/subscriptions/${subscriptionId}/`);
|
||||
});
|
@ -1,221 +0,0 @@
|
||||
const filterTableId = "#filterTable";
|
||||
const filterModalId = "#filterFormModal";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initFiltersModule() {
|
||||
initializeDataTable(
|
||||
filterTableId,
|
||||
[
|
||||
{
|
||||
title: "Name",
|
||||
data: "name",
|
||||
className: "col-4",
|
||||
render: renderEditColumn
|
||||
},
|
||||
{
|
||||
title: "Match",
|
||||
data: "match",
|
||||
className: "col-4"
|
||||
},
|
||||
{
|
||||
title: "Algorithm",
|
||||
data: "matching_algorithm",
|
||||
className: "col-2",
|
||||
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: "col-1 text-center",
|
||||
render: data => renderBooleanColumn(!data)
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
data: "is_whitelist",
|
||||
className: "col-1",
|
||||
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 Table Share Btn
|
||||
|
||||
// getTableFiltersComponent(filterTableId).filter(".js-tableShareBtn").on("click", async () => {
|
||||
// createModal({
|
||||
// title: "Share"
|
||||
// })
|
||||
// });
|
||||
|
||||
// region Table Delete Btns
|
||||
|
||||
$(filterModalId).find(".modal-del-btn").on("click", async function() {
|
||||
$(filterModalId).modal("hide");
|
||||
|
||||
const id = parseInt($(filterModalId).data("primary-key"));
|
||||
const filter = $(filterTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||
const name = sanitise(filter.name);
|
||||
|
||||
const deleteFilter = async () => {
|
||||
await ajaxRequest(`/api/filters/${filter.id}/`, "DELETE");
|
||||
setTimeout(async () => {
|
||||
$(filterTableId).trigger("doDataLoad");
|
||||
await loadSubModalOptions(
|
||||
$(subModalId).find('[data-field="filters"]'),
|
||||
`/api/filters/?server=${selectedServer.id}`
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: "Delete a Content Filter",
|
||||
texts: [
|
||||
{
|
||||
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||
html: true
|
||||
},
|
||||
{ content: "This action is irreversible, you will lose this filter forever." }
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteFilter,
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true,
|
||||
onClick: async () => $(filterModalId).modal("show")
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
getTableFiltersComponent(filterTableId).find(".js-tableDeleteBtn").on("click", async function() {
|
||||
const rows = getSelectedTableRows(filterTableId);
|
||||
const isMany = rows.length > 1;
|
||||
const names = rows.map(row => row.name);
|
||||
|
||||
const deleteFilters = async () => {
|
||||
rows.forEach(async row => {
|
||||
await ajaxRequest(`/api/filters/${row.id}/`, "DELETE");
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
$(filterTableId).trigger("doDataLoad");
|
||||
await loadSubModalOptions(
|
||||
$(subModalId).find('[data-field="filters"]'),
|
||||
`/api/filters/?server=${selectedServer.id}`
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `Delete ${isMany ? "Many Content Filters" : "Content Filter"}`,
|
||||
texts: [
|
||||
{
|
||||
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} content filter${isMany ? "s" : ""}?</p>`,
|
||||
html: true
|
||||
},
|
||||
{
|
||||
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||
html: true
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteFilters
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region New/Edit Modal
|
||||
|
||||
$(filterTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".js-tableAddBtn", 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 $input = $(filterModalId).find('[data-field="matching_algorithm"]');
|
||||
const data = await ajaxRequest("/api/filters/", "OPTIONS");
|
||||
data.actions.GET.matching_algorithm.choices.forEach(algorithm => {
|
||||
$input.append($(
|
||||
"<option>",
|
||||
{
|
||||
text: algorithm.display_name,
|
||||
value: algorithm.value > 0 ? algorithm.value : ""
|
||||
}
|
||||
));
|
||||
});
|
||||
if ($input.next('.corbz-select-container').length) {
|
||||
$input.next('.corbz-select-container').remove();
|
||||
$input.initCorbzSelect();
|
||||
}
|
||||
}
|
@ -1,301 +0,0 @@
|
||||
const styleTableId = "#styleTable";
|
||||
const styleModalId = "#styleFormModal";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initMessageStylesModule() {
|
||||
initializeDataTable(
|
||||
styleTableId,
|
||||
[
|
||||
{
|
||||
title: "Name",
|
||||
data: "name",
|
||||
className: "col-2",
|
||||
render: (name, type, style) => {
|
||||
const elem = renderEditColumn(name);
|
||||
return style.auto_created ?
|
||||
$(elem).removeClass("edit-modal").addClass("disabled").attr("role", null)[0]
|
||||
: elem;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Embed",
|
||||
data: "is_embed",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Colour",
|
||||
data: "colour",
|
||||
className: "col-1 text-center",
|
||||
render: renderHexColourColumn
|
||||
},
|
||||
{
|
||||
title: "Hyperlinked",
|
||||
data: "is_hyperlinked",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Authored",
|
||||
data: "show_author",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Timestamped",
|
||||
data: "show_timestamp",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Images",
|
||||
data: "show_images",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Fetch Images",
|
||||
data: "fetch_images",
|
||||
className: "col-1 text-center",
|
||||
render: renderBooleanColumn
|
||||
},
|
||||
{
|
||||
title: "Title Mutator",
|
||||
data: "title_mutator_detail",
|
||||
className: "col-1",
|
||||
render: (data, type, row) => row.title_mutator_detail.name
|
||||
},
|
||||
{
|
||||
title: "Description Mutator",
|
||||
data: "description_mutator_detail",
|
||||
className: "col-1",
|
||||
render: (data, type, row) => row.description_mutator_detail.name
|
||||
},
|
||||
{
|
||||
title: "Editable",
|
||||
data: "auto_created",
|
||||
className: "col-1 text-center",
|
||||
render: function(data) {
|
||||
const icon = renderBooleanColumn(!data);
|
||||
if (!data) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return $(`
|
||||
<span data-bs-trigger="hover focus"
|
||||
data-bs-custom-class="text-center"
|
||||
data-bs-toggle="popover"
|
||||
data-bs-content="This style was created internally, and cannot be altered.">
|
||||
${icon}
|
||||
</span>
|
||||
`).popover()[0];
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// region Load Data
|
||||
|
||||
$(document).on("selectedServerChange", async function() {
|
||||
await loadMessageStyleData();
|
||||
});
|
||||
|
||||
$(document).on("doDataLoad", async function() {
|
||||
await loadMessageStyleData();
|
||||
});
|
||||
|
||||
async function loadMessageStyleData() {
|
||||
if (!selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableFilter(styleTableId, "server", selectedServer.id);
|
||||
await loadTableData(styleTableId, "/api/message-styles/", "GET");
|
||||
}
|
||||
|
||||
// region Table Delete Btns
|
||||
|
||||
$(styleModalId).find(".modal-del-btn").on("click", async function() {
|
||||
$(styleModalId).modal("hide");
|
||||
|
||||
const id = parseInt($(styleModalId).data("primary-key"));
|
||||
const style = $(styleTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||
const name = sanitise(style.name);
|
||||
|
||||
const deleteStyle = async () => {
|
||||
await ajaxRequest(`/api/message-styles/${style.id}/`, "DELETE");
|
||||
setTimeout(async () => {
|
||||
$(styleTableId).trigger("doDataLoad");
|
||||
await loadSubModalOptions(
|
||||
$(subModalId).find('[data-field="message_style"]'),
|
||||
`/api/message-styles/?server=${selectedServer.id}`
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: "Delete a Message Style",
|
||||
texts: [
|
||||
{
|
||||
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||
html: true
|
||||
},
|
||||
{ content: "This action is irreversible, you will lose this filter forever." }
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteStyle,
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true,
|
||||
onClick: async () => $(styleModalId).modal("show")
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
getTableFiltersComponent(styleTableId).find(".js-tableDeleteBtn").on("click", async function() {
|
||||
const rows = getSelectedTableRows(styleTableId);
|
||||
const isMany = rows.length > 1;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.auto_created) {
|
||||
continue;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: "Cannot Delete Style",
|
||||
texts: [
|
||||
{
|
||||
content: `<p><b>${sanitise(row.name)}</b> can't be deleted, as it was created by the system.</p>`,
|
||||
html: true
|
||||
},
|
||||
{ content: "System-owned styles cannot be modified or deleted." },
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-warning px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
return
|
||||
}
|
||||
|
||||
const names = rows.map(row => row.name);
|
||||
|
||||
const deleteStyles = async () => {
|
||||
rows.forEach(async row => {
|
||||
await ajaxRequest(`/api/message-styles/${row.id}/`, "DELETE");
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
$(styleTableId).trigger("doDataLoad");
|
||||
await loadSubModalOptions(
|
||||
$(subModalId).find('[data-field="message_style"]'),
|
||||
`/api/message-styles/?server=${selectedServer.id}`
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `Delete ${isMany ? "Many Message Styles" : "Message Style"}`,
|
||||
texts: [
|
||||
{
|
||||
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} message style${isMany ? "s" : ""}?</p>`,
|
||||
html: true
|
||||
},
|
||||
{
|
||||
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||
html: true
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteStyles
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region New/Edit Modal
|
||||
|
||||
$(styleTableId).closest(".js-tableBody").siblings(".js-tableFilters").on("click", ".js-tableAddBtn", async function() {
|
||||
await openDataModal(styleModalId, -1);
|
||||
});
|
||||
|
||||
$(styleTableId).on("click", ".edit-modal", async function() {
|
||||
let id = $(styleTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||
await openDataModal(styleModalId, id, `/api/message-styles/${id}/`);
|
||||
});
|
||||
|
||||
$(styleModalId).on("submit", async function(event) {
|
||||
event.preventDefault();
|
||||
onModalSubmit(
|
||||
$(styleModalId),
|
||||
$(styleTableId),
|
||||
"/api/message-styles/"
|
||||
).then(async () => {
|
||||
// Reload sub data to reflect style changes
|
||||
$(subTableId).trigger("doDataLoad");
|
||||
await loadSubModalOptions(
|
||||
$(subModalId).find('[data-field="message_style"]'),
|
||||
`/api/message-styles/?server=${selectedServer.id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region Load Mutator Options
|
||||
|
||||
$(document).ready(async function() {
|
||||
await loadMutatorOptions();
|
||||
});
|
||||
|
||||
async function loadMutatorOptions() {
|
||||
let $inputs = $(styleModalId).find('[data-field="title_mutator"], [data-field="description_mutator"]');
|
||||
|
||||
$inputs.val("").change();
|
||||
$inputs.prop("disabled", true);
|
||||
|
||||
$inputs.find("option").each(function() {
|
||||
if ($(this).val()) {
|
||||
$(this).remove();
|
||||
}
|
||||
});
|
||||
|
||||
const data = await ajaxRequest("/api/message-mutators/?page_size=25", "GET");
|
||||
data.results.forEach(mutator => {
|
||||
$inputs.append($(
|
||||
"<option>",
|
||||
{text: mutator.name, value: mutator.id}
|
||||
));
|
||||
});
|
||||
|
||||
$inputs.prop("disabled", false);
|
||||
|
||||
// Re-init the component
|
||||
if ($inputs.next('.corbz-select-container').length) {
|
||||
$inputs.next('.corbz-select-container').remove();
|
||||
$inputs.initCorbzSelect();
|
||||
}
|
||||
}
|
@ -1,330 +0,0 @@
|
||||
|
||||
const subTableId = "#subTable";
|
||||
const subModalId = "#subFormModal";
|
||||
|
||||
|
||||
// region Init Module
|
||||
|
||||
function initSubscriptionsModule() {
|
||||
initializeDataTable(
|
||||
subTableId,
|
||||
[
|
||||
{
|
||||
title: "Name",
|
||||
data: "name",
|
||||
className: "col-3",
|
||||
render: renderEditColumn
|
||||
},
|
||||
{
|
||||
title: "URL",
|
||||
data: "url",
|
||||
className: "col-4",
|
||||
render: url => renderAnchorColumn(url, url)
|
||||
},
|
||||
{
|
||||
title: "Channels",
|
||||
data: "channels_detail",
|
||||
className: "col-1 text-center",
|
||||
render: data => renderPopoverBadgesColumn(data.map(item => `#${item.name}`), "bi-hash")
|
||||
},
|
||||
{
|
||||
title: "Filters",
|
||||
data: "filters_detail",
|
||||
className: "col-1 text-center",
|
||||
render: data => renderPopoverBadgesColumn(data.map(item => item.name), "bi-funnel")
|
||||
},
|
||||
{
|
||||
title: "Style",
|
||||
data: "message_style_detail",
|
||||
className: "col-1 text-center",
|
||||
render: renderLinkToStyleColumn
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
data: "created_at",
|
||||
className: "col-1",
|
||||
render: function(data, type) {
|
||||
let dateTime = new Date(data);
|
||||
return $(`
|
||||
<span data-bs-trigger="hover focus"
|
||||
data-bs-html="true"
|
||||
data-bs-custom-class="text-center"
|
||||
data-bs-toggle="popover"
|
||||
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
|
||||
${formatStringDate(dateTime, "%D, %b %Y")}
|
||||
</span>
|
||||
`).popover()[0];
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
data: "active",
|
||||
className: "col-1 text-center form-switch",
|
||||
render: function(data, type) {
|
||||
return `<input type="checkbox" class="sub-toggle-active form-check-input ms-0" ${data ? "checked" : ""} />`
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// region Load Data
|
||||
|
||||
$(document).on("selectedServerChange", async function() {
|
||||
await loadSubscriptionData();
|
||||
});
|
||||
|
||||
$(subTableId).on("doDataLoad", async function() {
|
||||
await loadSubscriptionData();
|
||||
})
|
||||
|
||||
async function loadSubscriptionData() {
|
||||
if (!selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableFilter(subTableId, "server", selectedServer.id);
|
||||
await loadTableData(subTableId, `/api/subscriptions/`, "GET");
|
||||
}
|
||||
|
||||
|
||||
// region Table Switches
|
||||
|
||||
$(subTableId).on("change", ".sub-toggle-active", async function() {
|
||||
|
||||
// Temporarily disable all switches to prevent spam.
|
||||
$(subTableId).find(".sub-toggle-active").prop("disabled", true);
|
||||
setTimeout(() => { $(subTableId).find(".sub-toggle-active").prop("disabled", false); }, 800);
|
||||
|
||||
let active = $(this).prop("checked");
|
||||
let id = $(subTableId).DataTable().row($(this).closest("tr")).data().id;
|
||||
let sub = await ajaxRequest(`/api/subscriptions/${id}/`, "GET");
|
||||
sub.active = active;
|
||||
let formData = objectToFormData(sub);
|
||||
await ajaxRequest(`/api/subscriptions/${id}/`, "PUT", formData);
|
||||
});
|
||||
|
||||
|
||||
// region Table Delete Buttons
|
||||
|
||||
$(subModalId).find(".modal-del-btn").on("click", async function() {
|
||||
$(subModalId).modal("hide");
|
||||
|
||||
const id = parseInt($(subModalId).data("primary-key"));
|
||||
const subscription = $(subTableId).DataTable().row((idx, row) => { return row.id === id }).data();
|
||||
const name = sanitise(subscription.name);
|
||||
|
||||
const deleteSubscription = async () => {
|
||||
await ajaxRequest(`/api/subscriptions/${subscription.id}/`, "DELETE");
|
||||
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: "Delete a Subscriptions",
|
||||
texts: [
|
||||
{
|
||||
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
|
||||
html: true
|
||||
},
|
||||
{ content: "This action is irreversible, you will lose this subscription forever." }
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteSubscription,
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true,
|
||||
onClick: async () => $(subModalId).modal("show")
|
||||
}
|
||||
]
|
||||
});
|
||||
})
|
||||
|
||||
getTableFiltersComponent(subTableId).find(".js-tableDeleteBtn").on("click", async function() {
|
||||
const rows = getSelectedTableRows(subTableId);
|
||||
const names = rows.map(row => row.name);
|
||||
const isMany = names.length > 1;
|
||||
|
||||
const deleteSubscriptions = () => {
|
||||
rows.forEach(async row => {
|
||||
await ajaxRequest(`/api/subscriptions/${row.id}/`, "DELETE");
|
||||
});
|
||||
|
||||
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `Delete ${isMany ? "Many Subscriptions" : "Subscription"}`,
|
||||
texts: [
|
||||
{
|
||||
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "this"} subscription${isMany ? "s" : ""}?</p>`,
|
||||
html: true
|
||||
},
|
||||
{
|
||||
content: arrayToHtmlList(names, true).prop("outerHTML"),
|
||||
html: true
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
className: "btn-danger me-3",
|
||||
iconClass: "bi-trash3",
|
||||
closeModal: true,
|
||||
onClick: deleteSubscriptions
|
||||
},
|
||||
{
|
||||
className: "btn-secondary px-4",
|
||||
iconClass: "bi-arrow-return-right",
|
||||
closeModal: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// region New/Edit Modal
|
||||
|
||||
$(subTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".js-tableAddBtn", 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}`
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Re-init the component
|
||||
if ($input.next('.corbz-select-container').length) {
|
||||
$input.next('.corbz-select-container').remove();
|
||||
$input.initCorbzSelect();
|
||||
}
|
||||
}
|
||||
|
||||
// Channel options aren't loaded from an API, like other options.
|
||||
async function loadChannelOptions() {
|
||||
$input = $(subModalId).find('[data-field="channels"]');
|
||||
|
||||
$input.val("").change();
|
||||
$input.prop("disabled", true);
|
||||
|
||||
$input.find("option").each(function() {
|
||||
if ($(this).val()) {
|
||||
$(this).remove();
|
||||
}
|
||||
});
|
||||
|
||||
const data = await loadedChannels(selectedServer.id);
|
||||
data.forEach(item => {
|
||||
$input.append($(
|
||||
"<option>",
|
||||
{text: `#${item.name}`, value: item.id}
|
||||
));
|
||||
})
|
||||
|
||||
$input.prop("disabled", false);
|
||||
|
||||
// Re-init the component
|
||||
if ($input.next('.corbz-select-container').length) {
|
||||
$input.next('.corbz-select-container').remove();
|
||||
$input.initCorbzSelect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// region Open Sub Style
|
||||
|
||||
$(subTableId).on("click", ".js-openSubStyle", async event => {
|
||||
const subTable = $(subTableId).DataTable();
|
||||
const row = subTable.row($(event.currentTarget).closest("tr"));
|
||||
const styleId = row.data().message_style
|
||||
|
||||
// Open styles tab and styles modal
|
||||
$("#stylesTab").click();
|
||||
await openDataModal(styleModalId, styleId, `/api/message-styles/${styleId}/`);
|
||||
});
|
||||
|
||||
|
||||
// region Sub Recommendations
|
||||
|
||||
$(document).ready(async () => {
|
||||
const response = await ajaxRequest("/api/subscription-recommendations/", "GET");
|
||||
response.results.forEach(recommendation => {
|
||||
const template = $($("#subscriptionRecommendationTemplate").html());
|
||||
|
||||
template.find(".js-title").text(recommendation.name);
|
||||
template.find(".js-desc").text(recommendation.description);
|
||||
template.find(".js-url").text(recommendation.url).attr("href", recommendation.url);
|
||||
|
||||
$(".js-subscription-recommendations-group").append(template);
|
||||
});
|
||||
});
|
||||
|
||||
$(".js-subscription-recommendations-group").on("click", ".js-subRecBtn", async function() {
|
||||
const name = $(this).find(".js-title").text();
|
||||
const url = $(this).find(".js-url").text();
|
||||
|
||||
$("#subRecommendationModal").modal("hide");
|
||||
await openDataModal(subModalId, -1);
|
||||
|
||||
$("#subName").val(name);
|
||||
$("#subUrl").val(url);
|
||||
});
|
@ -1,204 +0,0 @@
|
||||
|
||||
.render-array-dropdown-column {
|
||||
|
||||
position: relative;
|
||||
|
||||
.dropdown {
|
||||
|
||||
}
|
||||
|
||||
// .col-badge {
|
||||
|
||||
// position: absolute;
|
||||
// top: 50%;
|
||||
// left: 50%;
|
||||
// transform: translate(-50%, -50%);
|
||||
// background-color: var(--bs-danger);
|
||||
// border-radius: 50%;
|
||||
// width: 1.25rem;
|
||||
// height: 1.25rem;
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
// flex-shrink: 0;
|
||||
|
||||
// >* {
|
||||
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
button[data-bs-toggle="dropdown"] {
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
|
||||
}
|
||||
|
||||
// .col-badge {
|
||||
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// right: 100%;
|
||||
// transform: translateY(-100%);
|
||||
// background-color: var(--bs-danger);
|
||||
|
||||
// }
|
||||
|
||||
.dropdown-menu {
|
||||
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
box-shadow: var(--bs-box-shadow);
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
|
||||
.dropdown-item {
|
||||
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
|
||||
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.table {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.table tbody tr.selected > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.table.dataTable > tbody > tr.selected a {
|
||||
color: var(--bs-link-color) !important;
|
||||
}
|
||||
|
||||
/* Fuck ugly <td> height fix */
|
||||
td {
|
||||
height: 1px;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
td > .btn-link { padding-left: 0; }
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
tr { height: 100%; }
|
||||
td { height: 100%; }
|
||||
}
|
||||
|
||||
|
||||
/* Empty Table */
|
||||
|
||||
.table .dt-empty {
|
||||
padding: 2rem 0;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.table tr:hover > .dt-empty {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Table Search */
|
||||
|
||||
.table-search-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-search-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
padding-right: 0;
|
||||
border: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.table-search-input {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
appearance: none;
|
||||
background-clip: padding-box;
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 1%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: inherit
|
||||
}
|
||||
|
||||
.table-search-input:focus {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
/* Button Controls */
|
||||
|
||||
.table-search-buttons > div {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.table-search-buttons > div {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table-search-buttons > div:first-of-type {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Table Border Colour */
|
||||
|
||||
table.dataTable > thead > tr > th, table.dataTable > thead > tr > td {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
div.dt-container.dt-empty-footer tbody > tr:last-child > * {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
|
||||
/* Cell Data Types */
|
||||
|
||||
.table-cell-hex {
|
||||
margin: 0 auto;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
@ -1,214 +0,0 @@
|
||||
@import "bootstrap.scss";
|
||||
@import "./sidebar.scss";
|
||||
@import "./tables.scss";
|
||||
|
||||
|
||||
/* widths */
|
||||
|
||||
.mw-10rem {
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
.col-switch-width {
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* Server Tabs */
|
||||
|
||||
#serverTabs .nav-item .nav-link:hover:not(.active) {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
#serverTabs .nav-item .nav-link:not(.active) {
|
||||
color: var(--bs-text-body);
|
||||
}
|
||||
|
||||
.act-as-link {
|
||||
|
||||
color: rgba(var(--bs-link-color-rgb), 1);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--bs-link-hover-color-rgb); }
|
||||
&:disabled, &.disabled { color: var(--bs-secondary-color) }
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Custom Select Component
|
||||
|
||||
.corbz-select.is-invalid +.corbz-select-container {
|
||||
|
||||
border-color: var(--bs-form-invalid-border-color);
|
||||
|
||||
.corbz-select-dropdown {
|
||||
|
||||
border-top: 0;
|
||||
border-color: var(--bs-form-invalid-border-color);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.corbz-select-container {
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
appearance: none;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
background-clip: padding-box;
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
outline: 0 !important;
|
||||
|
||||
&.active {
|
||||
|
||||
border-radius: var(--bs-border-radius-sm) var(--bs-border-radius-sm) 0 0;
|
||||
|
||||
.corbz-select-selected::after {
|
||||
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.corbz-select-selected {
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.375rem 0.75rem;
|
||||
padding-right: calc(1.5em + 0.75rem);
|
||||
cursor: pointer;
|
||||
|
||||
// Arrow icon
|
||||
&::after {
|
||||
|
||||
content: "\f282";
|
||||
font-family: "bootstrap-icons";
|
||||
font-weight: normal;
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
right: 0.9rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.corbz-select-dropdown {
|
||||
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: calc(100% + 2.25px);
|
||||
transform: translateX(-1px);
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0 0 var(--bs-border-radius-sm) var(--bs-border-radius-sm);
|
||||
box-shadow: var(--bs-box-shadow);
|
||||
z-index: 1000;
|
||||
outline: 0;
|
||||
|
||||
.corbz-select-search {
|
||||
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
outline: none;
|
||||
|
||||
}
|
||||
|
||||
.corbz-select-text {
|
||||
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
@extend small;
|
||||
|
||||
}
|
||||
|
||||
.corbz-select-dropdown-options {
|
||||
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
.corbz-select-option {
|
||||
|
||||
padding: 0.375rem 0.75rem;
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
|
||||
&:hover, &:focus { background-color: var(--bs-tertiary-bg); }
|
||||
&.active {
|
||||
|
||||
color: var(--bs-white);
|
||||
background-color: var(--bs-primary);
|
||||
|
||||
}
|
||||
|
||||
// Multi-select versions
|
||||
> .corbz-option-checkbox {
|
||||
|
||||
--bs-form-check-bg-image: none;
|
||||
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
appearance: none;
|
||||
background-color: var(--bs-body-bg);
|
||||
background-image: var(--bs-form-check-bg-image);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
vertical-align: middle;
|
||||
outline: 0;
|
||||
|
||||
&:checked {
|
||||
|
||||
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
> span {
|
||||
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,346 +0,0 @@
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(var(--bs-secondary-rgb), 0.50);
|
||||
z-index: 998;
|
||||
display: none; /* Must start as hidden! */
|
||||
}
|
||||
|
||||
.reveal-sidebar-btn {
|
||||
z-index: 998;
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
|
||||
@include media-breakpoint-down(lg) { display: block; }
|
||||
|
||||
}
|
||||
|
||||
.server-rate-limit { display: none; }
|
||||
|
||||
.sidebar {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-secondary-bg-subtle);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
z-index: 999;
|
||||
|
||||
// Hide the sidebar
|
||||
@include media-breakpoint-down(lg) {
|
||||
transform: translateX(-100%);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
// Show the sidebar on smaller screens
|
||||
&.visible {
|
||||
@include media-breakpoint-down(lg) { transform: translateX(0); }
|
||||
@include media-breakpoint-down(sm) { width: 100vw; }
|
||||
}
|
||||
|
||||
.sidebar-divider { margin: 1rem; }
|
||||
|
||||
.sidebar-header {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
.sidebar-header-link {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
.sidebar-logo {
|
||||
|
||||
width: 45px;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Hide on larger screens, show on smaller screens
|
||||
.btn-close {
|
||||
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-down(lg) { display: block; }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
|
||||
padding: 0 1rem ;
|
||||
margin-bottom: auto;
|
||||
list-style: none;
|
||||
|
||||
.sidebar-placeholder {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
|
||||
.sidebar-placeholder-image {
|
||||
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-right: 0.75rem;
|
||||
border-radius: $border-radius-sm;
|
||||
|
||||
}
|
||||
|
||||
.sidebar-placeholder-data {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
>.placeholder { border-radius: $border-radius-sm; }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: $border-radius-sm;
|
||||
background-color: inherit;
|
||||
|
||||
// Highlight effect
|
||||
&:not(:disabled):hover,
|
||||
&:not(:disabled):focus,
|
||||
&.active {
|
||||
|
||||
background-color: var(--bs-body-bg);
|
||||
|
||||
&.spot {
|
||||
|
||||
border-radius: 0 $border-radius-sm $border-radius-sm 0;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 'Spot' is an alert indicator, that appears as a coloured dot against a sidebar item
|
||||
&.spot {
|
||||
|
||||
&::before {
|
||||
|
||||
content: "";
|
||||
transition: 0.1s ease;
|
||||
transform: translate(-100%, -50%);
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bs-body-bg);
|
||||
|
||||
}
|
||||
|
||||
&:not(:disabled):hover::before,
|
||||
&:not(:disabled):focus::before,
|
||||
&.active::before {
|
||||
|
||||
transition: 0.15s ease;
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem 0 0 0.25rem;
|
||||
|
||||
}
|
||||
|
||||
// Spot Colours
|
||||
&.spot-primary::before { background-color: var(--bs-primary); }
|
||||
&.spot-secondary::before { background-color: var(--bs-secondary); }
|
||||
&.spot-success::before { background-color: var(--bs-success); }
|
||||
&.spot-danger::before { background-color: var(--bs-danger); }
|
||||
&.spot-warning::before { background-color: var(--bs-warning); }
|
||||
&.spot-info::before { background-color: var(--bs-info); }
|
||||
|
||||
}
|
||||
|
||||
.sidebar-item-image {
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: $border-radius-sm;
|
||||
margin-right: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
// Includes the server name and id
|
||||
.sidebar-item-data {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&>span {
|
||||
|
||||
text-align: start;
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
|
||||
display: flex;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: $border-radius-sm;
|
||||
color: var(--bs-body-color);
|
||||
background-color: inherit;
|
||||
|
||||
&:hover, &:focus, &.active, &:has(>.show) { background-color: var(--bs-body-bg); }
|
||||
|
||||
&.sidebar-mini-btn {
|
||||
|
||||
justify-content: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
|
||||
}
|
||||
|
||||
&.dropdown { padding: 0; }
|
||||
|
||||
>.sidebar-menu-btn {
|
||||
|
||||
text-align: start;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
border-radius: $border-radius-sm;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sidebar-avatar {
|
||||
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
&.show {
|
||||
display: flex;
|
||||
inset: auto auto 0 auto !important;
|
||||
// transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
> li:not(:last-child) { margin-right: 0.25rem; }
|
||||
|
||||
.theme-btn {
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.25rem;
|
||||
border: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
color: var(--bs-body-color);
|
||||
background-color: inherit;
|
||||
border-radius: $border-radius-sm;
|
||||
|
||||
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||
|
||||
&.active {
|
||||
|
||||
color: var(--bs-white);
|
||||
background-color: var(--bs-primary);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
box-shadow: var(--bs-box-shadow);
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
|
||||
.dropdown-item {
|
||||
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
|
||||
&:hover, &:focus { background-color: var(--bs-body-bg); }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,168 +0,0 @@
|
||||
|
||||
// Table Search Bar
|
||||
.table-search-group {
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
height: fit-content;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
|
||||
.table-search-label {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
padding-right: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
.table-search-input {
|
||||
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
padding: 0.375rem 0.75rem;
|
||||
appearance: none;
|
||||
width: 1%;
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
background-clip: padding-box;
|
||||
|
||||
// Disable bootstrap's focus highlights
|
||||
&:focus {
|
||||
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.table-search-button {
|
||||
|
||||
@extend .btn;
|
||||
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
border-left: 1px solid var(--bs-border-color) !important;
|
||||
border-right: 1px solid var(--bs-border-color) !important;
|
||||
margin-right: -1px;
|
||||
|
||||
&:focus, &:hover { background-color: var(--bs-tertiary-bg); }
|
||||
&:active, &.active { background-color: var(--bs-secondary); }
|
||||
&:disabled, &.disabled {
|
||||
|
||||
color: var(--bs-tertiary-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Badge popover
|
||||
.table-badge-popover .popover-body {
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 0.5rem 0.5rem 1rem;
|
||||
max-width: 200px;
|
||||
|
||||
}
|
||||
|
||||
// Table Button Controls
|
||||
|
||||
.table-button-controls > div {
|
||||
|
||||
margin-left: 1rem;
|
||||
|
||||
&:first-of-type { margin-left: 0 !important; }
|
||||
|
||||
@include media-breakpoint-down(md) { margin-left: 0.25rem; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Table
|
||||
.table {
|
||||
|
||||
td, th {
|
||||
|
||||
vertical-align: middle;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
// Top & bottom border colours
|
||||
border-color: var(--bs-border-color);
|
||||
tr:first-child > *,
|
||||
tr:last-child > * {
|
||||
|
||||
border-color: inherit !important;
|
||||
|
||||
}
|
||||
|
||||
tr {
|
||||
|
||||
height: 50px;
|
||||
|
||||
// Selected rows
|
||||
&.selected {
|
||||
|
||||
> * {
|
||||
|
||||
color: var(--bs-body-color) !important;
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
color: var(--bs-link-color) !important;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
thead { }
|
||||
tbody { }
|
||||
tfoot { }
|
||||
|
||||
.col-hex-icon {
|
||||
margin: 0 auto;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
}
|
@ -1,199 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
{% block title %}{% endblock title %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{% compress css %}
|
||||
<link type="text/x-scss" rel="stylesheet" href="{% static '/home/scss/index.scss' %}">
|
||||
{% endcompress %}
|
||||
<link type="text/css" rel="stylesheet" href="{% static '/home/css/tables.css' %}">
|
||||
<link type="text/css" rel="stylesheet" href="{% static '/css/select2.css' %}">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-0 h-100">
|
||||
<div class="d-flex flex-nowrap h-100">
|
||||
|
||||
{% include "home/sidebar.html" %}
|
||||
|
||||
<div class="flex-grow-1 container-fluid bg-body overflow-y-auto" style="min-width: 0;">
|
||||
<div id="noSelectedServer" class="h-100">
|
||||
<div class="d-flex justify-content-center align-items-center flex-column h-100">
|
||||
<img src="{% static '/images/pyrss_logo.webp' %}" alt="PYRSS Logo">
|
||||
<h1 class="fw-bold mb-4 font-atkinson-hyperlegible">PYRSS</h1>
|
||||
<div class="d-flex align-items-center flex-nowrap flex-column">
|
||||
<p class="col-lg-8 text-center">Select a server from the left hand menu to get started. For more help check the <a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-decoration-none" target="_blank">README</a>.</p>
|
||||
<div class="col-lg-8 text-center">
|
||||
<h5>Resources</h5>
|
||||
<div class="hstack gap-3 justify-content-center">
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="text-body text-decoration-none" target="_blank"><i class="bi bi-git fs-3"></i></a>
|
||||
<a href="https://en.wikipedia.org/wiki/RSS" class="text-body text-decoration-none" target="_blank"><i class="bi bi-rss-fill fs-3"></i></a>
|
||||
<a href="https://discord.com/developers/docs/intro" class="text-body text-decoration-none" target="_blank"><i class="bi bi-discord fs-3"></i></a>
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-body text-decoration-none" target="_blank"><i class="bi bi-question-circle-fill fs-3"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="selectedServerContainer" class="row" style="display: none;">
|
||||
<div id="serverJoinAlert" class="col-12 m-0">
|
||||
<div class="alert alert-warning rounded-1 px-sm-3 mt-2 mt-sm-4 mx-sm-2">
|
||||
<div class="row">
|
||||
<div class="col-xxl-10 d-flex align-items-center">
|
||||
<div class="mb-4 mb-xxl-0">
|
||||
<strong>Warning:</strong>
|
||||
<br class="d-xxl-none">
|
||||
The Bot isn't a member of
|
||||
<span class="resolve-to-server-name"></span>,
|
||||
features here will not function properly, please add the bot before proceeding.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xxl-2 text-xxl-end">
|
||||
<a class="btn btn-warning rounded-1 text-nowrap resolve-to-invite-link" target="_blank">Add PYRSS</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 bg-body-tertiary">
|
||||
<div class="row py-3 px-sm-3">
|
||||
<div class="col-sm-4 col-xxl-3 mb-4 mb-sm-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<img class="resolve-to-server-icon rounded-1 me-3 text-center" width="40">
|
||||
<div>
|
||||
<h5 class="resolve-to-server-name mb-0"></h5>
|
||||
<h6 class="resolve-to-server-id small mb-0"></h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-8 col-xxl-9">
|
||||
<ul class="nav nav-pills justify-content-sm-end" role="tablist">
|
||||
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||
<button id="subscriptionsTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#subscriptionsTabPane" type="button" aria-controls="subscriptionsTabPane" aria-selected="false">
|
||||
<i class="bi bi-layers"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Subscriptions</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||
<button id="filtersTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#filtersTabPane" type="button" aria-controls="filtersTabPane" aria-selected="false">
|
||||
<i class="bi bi-funnel"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Content Filters</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-lg-3" role="presentation">
|
||||
<button id="stylesTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#stylesTabPane" type="button" aria-controls="stylesTabPane" aria-selected="false">
|
||||
<i class="bi bi-border-style"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Message Styles</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item me-lg-3" role="presentation">
|
||||
<button id="contentTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#contentTabPane" type="button" aria-controls="contentTabPane" aria-selected="false">
|
||||
<i class="bi bi-archive"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Tracked Content</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item me-0 dropdown">
|
||||
<button type="button" class="nav-link dropdown-toggle rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button type="button" class="js-serverUsersBtn dropdown-item">
|
||||
<i class="bi bi-people"></i>
|
||||
<span class="ms-2">Other Users</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="js-serverHistoryBtn dropdown-item">
|
||||
<i class="bi bi-clock"></i>
|
||||
<span class="ms-2">Edit History</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="js-closeServerBtn dropdown-item">
|
||||
<i class="bi bi-arrow-return-right"></i>
|
||||
<span class="ms-2">Close Server</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="js-eraseServerBtn dropdown-item text-danger">
|
||||
<i class="bi-trash3"></i>
|
||||
<span class="ms-2">Delete Data</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div id="serverTabContent" class="tab-content">
|
||||
<div id="subscriptionsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="subscriptionsTab" tabindex="0">
|
||||
{% include "home/tabs/subs.html" %}
|
||||
</div>
|
||||
<div id="filtersTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="filtersTab" tabindex="0">
|
||||
{% include "home/tabs/filters.html" %}
|
||||
</div>
|
||||
<div id="stylesTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="stylesTab" tabindex="0">
|
||||
{% include "home/tabs/styles.html" %}
|
||||
</div>
|
||||
<div id="contentTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="contentTab" tabindex="0">
|
||||
{% include "home/tabs/content.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "home/modals/editSub.html" %}
|
||||
{% include "home/modals/editFilter.html" %}
|
||||
{% include "home/modals/editStyle.html" %}
|
||||
{% include "home/modals/editServer.html" %}
|
||||
{% include "home/modals/modals.html" %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block javascript %}
|
||||
<script id="serverItemTemplate" type="text/template">
|
||||
<li>
|
||||
<button type="button" class="sidebar-item">
|
||||
<img src="" alt="" class="sidebar-item-image js-image">
|
||||
<div class="sidebar-item-data">
|
||||
<span class="js-name"></span>
|
||||
<span class="js-id text-body-secondary font-monospace"></span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</script>
|
||||
<script id="serverItemIconTemplate" type="text/template">
|
||||
<div class="dot-container m-1" data-bs-toggle="tooltip" data-bs-placement="right">
|
||||
<i class="dot-icon bg-warning "></i>
|
||||
</div>
|
||||
</script>
|
||||
<script id="subscriptionRecommendationTemplate" type="text/template">
|
||||
<div class="col-lg-6">
|
||||
<div class="bg-body-tertiary rounded-1 p-3 h-100 js-subRecBtn" role="button">
|
||||
<h6 class="js-title mb-3"></h6>
|
||||
<p class="js-desc mb-3"></p>
|
||||
<p class="mb-0">
|
||||
<a class="js-url text-decoration-none line-break-anywhere"></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script src="{% static 'js/api.js' %}"></script>
|
||||
<script src="{% static 'home/js/index.js' %}"></script>
|
||||
<script src="{% static 'home/js/modals.js' %}"></script>
|
||||
<script src="{% static 'home/js/servers.js' %}"></script>
|
||||
<script src="{% static 'home/js/tables.js' %}"></script>
|
||||
<script src="{% static 'home/js/tabs/subs.js' %}"></script>
|
||||
<script src="{% static 'home/js/tabs/filters.js' %}"></script>
|
||||
<script src="{% static 'home/js/tabs/content.js' %}"></script>
|
||||
<script src="{% static 'home/js/tabs/styles.js' %}"></script>
|
||||
{% endblock javascript %}
|
@ -1,67 +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 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="corbz-select" 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>
|
||||
<span class="ms-2 d-none d-sm-inline">Save</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3 px-3 px-sm-4" data-bs-dismiss="modal" tabindex="8">
|
||||
<i class="bi bi-arrow-return-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,109 +0,0 @@
|
||||
<div id="styleFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-1">
|
||||
<form id="styleForm" class="mb-0" novalidate>
|
||||
<div class="modal-header border-bottom-0">
|
||||
<h5 class="modal-title ms-2">
|
||||
<span class="form-create">Add</span>
|
||||
<span class="form-edit">Edit</span>
|
||||
Message Style
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="mb-4">
|
||||
<label for="styleName" class="form-label">Name</label>
|
||||
<input type="text" name="styleName" id="styleName" class="form-control rounded-1" data-field="name" tabindex="1">
|
||||
<div class="form-text">Human-readable name for this entry.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 ps-lg-4">
|
||||
<div class="mb-4">
|
||||
<div class="colour-input"
|
||||
data-id="styleEmbedColour"
|
||||
data-label="Embed Colour"
|
||||
data-helptext="Colour of the embed (if enabled)."
|
||||
data-defaultcolour="#3498db"
|
||||
data-field="colour">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleIsEmbed" class="form-check-label">Use an Embed</label>
|
||||
<input type="checkbox" name="styleIsEmbed" id="styleIsEmbed" class="form-check-input" data-field="is_embed" tabindex="2">
|
||||
<div class="form-text">Display in an Embed, instead of plain text.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 ps-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleIsHyperlinked" class="form-check-label">Hyperlink the Title</label>
|
||||
<input type="checkbox" name="styleIsHyperlinked" id="styleIsHyperlinked" class="form-check-input" data-field="is_hyperlinked" tabindex="3">
|
||||
<div class="form-text">Click the title to go to the url.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleShowAuthor" class="form-check-label">Show the Author</label>
|
||||
<input type="checkbox" name="styleShowAuthor" id="styleShowAuthor" class="form-check-input" data-field="show_author" tabindex="4">
|
||||
<div class="form-text">Show the content author if possible.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 ps-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleShowTimestamp" class="form-check-label">Show the Publish Date</label>
|
||||
<input type="checkbox" name="styleShowTimestamp" id="styleShowTimestamp" class="form-check-input" data-field="show_timestamp" tabindex="5">
|
||||
<div class="form-text">Show when the content was published.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleShowImages" class="form-check-label">Show any Images</label>
|
||||
<input type="checkbox" name="styleShowImages" id="styleShowImages" class="form-check-input" data-field="show_images" tabindex="6">
|
||||
<div class="form-text">Show any found images.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 ps-lg-4">
|
||||
<div class="form-check form-switch mb-4">
|
||||
<label for="styleFetchImages" class="form-check-label">Fetch missing Images</label>
|
||||
<input type="checkbox" name="styleFetchImages" id="styleFetchImages" class="form-check-input" data-field="fetch_images" tabindex="7">
|
||||
<div class="form-text">If images aren't found, try to fetch them.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="mb-4 mb-lg-0">
|
||||
<label for="styleTitleMutator" class="form-label">Title Mutator</label>
|
||||
<select name="styleTitleMutator" id="styleTitleMutator" class="corbz-select" 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="corbz-select" 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>
|
||||
<span class="ms-2 d-none d-sm-inline">Save</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3 px-3 px-sm-4" data-bs-dismiss="modal" tabindex="12">
|
||||
<i class="bi bi-arrow-return-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,136 +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 needs-validation" novalidate>
|
||||
<div class="modal-header border-bottom-0">
|
||||
<h5 class="modal-title ms-2">
|
||||
<span class="form-create">Add</span>
|
||||
<span class="form-edit">Edit</span>
|
||||
Subscription
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<input type="hidden" data-role="is-id">
|
||||
<div class="row">
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="mb-4">
|
||||
<label for="subName" class="form-label">Name</label>
|
||||
<input type="text" id="subName" name="subName" class="form-control rounded-1" required data-field="name">
|
||||
<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">
|
||||
<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="corbz-select" data-field="message_style" data-default="firstOption">
|
||||
</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">
|
||||
<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="corbz-select" multiple data-field="channels" tabindex="5"></select>
|
||||
<div class="form-text">Send content to these channels.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 ps-lg-4">
|
||||
<div class="mb-4">
|
||||
<label for="subFilters" class="form-label">Filters</label>
|
||||
<select name="subFilters" id="subFilters" class="corbz-select" multiple data-field="filters" tabindex="6"></select>
|
||||
<div class="form-text">Filter out unwanted content.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 pe-lg-4">
|
||||
<div class="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" data-default="true">
|
||||
<div class="form-text">Disabled Subscriptions will be ignored when processing content.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer px-4 border-top-0">
|
||||
<button type="button" class="btn btn-outline-secondary rounded-1 me-auto ms-0 form-create" data-bs-toggle="modal" data-bs-target="#subRecommendationModal">
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<span class="ms-2 d-none d-sm-inline">Recommend</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger rounded-1 me-3 ms-0 modal-del-btn form-edit">
|
||||
<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">
|
||||
<i class="bi bi-archive"></i>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4">
|
||||
<i class="bi bi-floppy"></i>
|
||||
<span class="ms-2 d-none d-sm-inline">Save</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3 px-3 px-sm-4" data-bs-dismiss="modal">
|
||||
<i class="bi bi-arrow-return-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="subRecommendationModal" 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 Recommendations</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<div class="container-fluid">
|
||||
<div class="row g-4 js-subscription-recommendations-group"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer px-4 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>
|
||||
|
||||
<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 px-4 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>
|
@ -1,32 +0,0 @@
|
||||
<div id="confirmModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-1">
|
||||
<div class="modal-header border-bottom-0">
|
||||
<h5 class="modal-title mx-2"></h5>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<p class="mb-0"></p>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0 px-4">
|
||||
<button type="button" class="btn rounded-1 modal-confirm-btn" tabindex="1">
|
||||
<i class="bi"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary rounded-1 ms-3 ms-0 px-4 modal-dismiss-btn" tabindex="2">
|
||||
<i class="bi bi-arrow-return-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="customModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-1">
|
||||
<div class="modal-header border-bottom-0">
|
||||
<h5 class="modal-title mx-2"></h5>
|
||||
</div>
|
||||
<div class="modal-body p-4"></div>
|
||||
<div class="modal-footer border-top-0 px-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,112 +0,0 @@
|
||||
{% load static %}
|
||||
<div class="sidebar-backdrop"></div>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="/" class="sidebar-header-link me-auto">
|
||||
<img src="{% static '/images/pyrss_logo.webp' %}" alt="pyrss logo" class="sidebar-logo">
|
||||
<span class="sidebar-title font-atkinson-hyperlegible">PYRSS</span>
|
||||
</a>
|
||||
<button type="button" class="btn-close"></button>
|
||||
</div>
|
||||
<hr class="sidebar-divider">
|
||||
<ul id="serverList" class="sidebar-content overflow-y-auto">
|
||||
<li class="server-rate-limit">
|
||||
<p class="text-danger">
|
||||
<span>Failed to fetch results - you are being rate limited by Discord.</span>
|
||||
<i class="bi bi-question-circle-fill" data-bs-toggle="tooltip" data-bs-title="Discord rate-limits when requests are made in rapid succession."></i>
|
||||
</p>
|
||||
<button type="button" class="sidebar-btn sidebar-retry-btn w-100 rounded-1">
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{% for i in "01234567890"|make_list %}
|
||||
<li class="sidebar-loading">
|
||||
<div class="sidebar-placeholder placeholder-wave">
|
||||
<div class="sidebar-placeholder-image placeholder"></div>
|
||||
<div class="sidebar-placeholder-data">
|
||||
<span class="placeholder mb-3 w-75"></span>
|
||||
<span class="placeholder {% if forloop.counter0|divisibleby:2 %}w-100{% else %}w-50{% endif %}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
<hr class="sidebar-divider">
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-btn text-start flex-grow-1 dropdown p-0">
|
||||
<button type="button" class="sidebar-menu-btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<img src="{{ request.user.avatar_url }}" alt="user icon" class="sidebar-avatar">
|
||||
<strong class="text-truncate">{{ request.user.global_name }}</strong>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="dropdown-item" target="_blank">
|
||||
<i class="bi bi-git me-2"></i>
|
||||
<span>Source Code</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/wiki" class="dropdown-item" target="_blank">
|
||||
<i class="bi bi-question-lg me-2"></i>
|
||||
<span>Help</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li>
|
||||
<a href="/admin/" class="dropdown-item" target="_blank">
|
||||
<i class="bi bi-person-check-fill me-2"></i>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<form action="/logout/" method="post" class="m-0">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sidebar-btn sidebar-mini-btn dropdown">
|
||||
<button type="button" class="js-themeMenuBtn sidebar-menu-btn px-3" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<i class="bi bi-sun"></i>
|
||||
</button>
|
||||
<ul class="theme-menu dropdown-menu dropdown-menu-center">
|
||||
<li>
|
||||
<input type="radio" name="themeToggle" id="themeToggleLight" class="btn-check" value="light" autocomplete="off">
|
||||
<label for="themeToggleLight" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Light Theme">
|
||||
<i class="bi bi-sun"></i>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="radio" name="themeToggle" id="themeToggleDark" class="btn-check" value="dark" autocomplete="off">
|
||||
<label for="themeToggleDark" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Dark Theme">
|
||||
<i class="bi bi-moon-stars"></i>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="radio" name="themeToggle" id="themeToggleAuto" class="btn-check" value="auto" autocomplete="off">
|
||||
<label for="themeToggleAuto" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Browser Preferred Theme">
|
||||
<i class="bi bi-circle-half"></i>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="js-pinSidebar sidebar-btn sidebar-mini-btn d-flex d-lg-none">
|
||||
<i class="bi bi-pin-angle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="reveal-sidebar-btn rounded-1 btn btn-lg btn-primary shadow">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
@ -1,68 +0,0 @@
|
||||
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4 d-flex">
|
||||
<div class="table-search-group mb-md-0 mb-3 dropdown">
|
||||
<label for="searchForContent" class="table-search-label">
|
||||
<i class="bi bi-search"></i>
|
||||
</label>
|
||||
<input type="search" id="searchForContent" class="table-search-input" placeholder="search">
|
||||
<button type="button" class="table-search-button dropdown-toggle" disabled data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-1">
|
||||
<li>
|
||||
<h6 class="dropdown-header">Sort By</h6>
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">Filter By</h6>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-7 col-xl-8 text-md-end table-button-controls">
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableRefreshBtn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<span class="ms-2">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableDeleteBtn btn btn-danger rounded-1" disabled>
|
||||
<i class="bi bi-trash3"></i>
|
||||
<span class="ms-2">Trash</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||
<table id="contentTable" class="table table-hover"></table>
|
||||
</div>
|
||||
<div class="js-tableControls row px-sm-3 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||
<ul class="pagination mb-0">
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-prev rounded-start-1">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-next rounded-end-1">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||
<label for="contentTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||
<select name="contentTablePageSizer" id="contentTablePageSizer" class="corbz-select 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>
|
@ -1,74 +0,0 @@
|
||||
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4 d-flex">
|
||||
<div class="table-search-group mb-md-0 mb-3 dropdown">
|
||||
<label for="searchForFilter" class="table-search-label">
|
||||
<i class="bi bi-search"></i>
|
||||
</label>
|
||||
<input type="search" id="searchForFilter" class="table-search-input" placeholder="search">
|
||||
<button type="button" class="table-search-button dropdown-toggle" disabled data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-1">
|
||||
<li>
|
||||
<h6 class="dropdown-header">Sort By</h6>
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">Filter By</h6>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-7 col-xl-8 text-md-end table-button-controls">
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableAddBtn btn btn-primary rounded-1">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span class="ms-2">Create</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableRefreshBtn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<span class="ms-2">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableDeleteBtn btn btn-danger rounded-1" disabled>
|
||||
<i class="bi bi-trash3"></i>
|
||||
<span class="ms-2">Trash</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||
<table id="filterTable" class="table table-hover"></table>
|
||||
</div>
|
||||
<div class="js-tableControls row px-sm-3 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||
<ul class="pagination mb-0">
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-prev rounded-start-1">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-next rounded-end-1">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||
<label for="filterTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||
<select name="filterTablePageSizer" id="filterTablePageSizer" class="corbz-select 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>
|
@ -1,74 +0,0 @@
|
||||
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4 d-flex">
|
||||
<div class="table-search-group mb-md-0 mb-3 dropdown">
|
||||
<label for="searchForStyle" class="table-search-label">
|
||||
<i class="bi bi-search"></i>
|
||||
</label>
|
||||
<input type="search" id="searchForStyle" class="table-search-input" placeholder="search">
|
||||
<button type="button" class="table-search-button dropdown-toggle" disabled data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-1">
|
||||
<li>
|
||||
<h6 class="dropdown-header">Sort By</h6>
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">Filter By</h6>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-7 col-xl-8 text-md-end table-button-controls">
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableAddBtn btn btn-primary rounded-1">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span class="ms-2">Create</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableRefreshBtn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<span class="ms-2">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableDeleteBtn btn btn-danger rounded-1" disabled>
|
||||
<i class="bi bi-trash3"></i>
|
||||
<span class="ms-2">Trash</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||
<table id="styleTable" class="table table-hover"></table>
|
||||
</div>
|
||||
<div class="js-tableControls row px-sm-3 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<nav class="table-pagination mb-4 mb-lg-0 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="styleTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||
<select name="styleTablePageSizer" id="styleTablePageSizer" class="corbz-select 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>
|
@ -1,74 +0,0 @@
|
||||
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4 d-flex">
|
||||
<div class="table-search-group mb-md-0 mb-3 dropdown">
|
||||
<label for="searchForSubscription" class="table-search-label">
|
||||
<i class="bi bi-search"></i>
|
||||
</label>
|
||||
<input type="search" id="searchForSubscription" class="table-search-input" placeholder="search">
|
||||
<button type="button" class="table-search-button dropdown-toggle" disabled data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-1">
|
||||
<li>
|
||||
<h6 class="dropdown-header">Sort By</h6>
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">Filter By</h6>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-7 col-xl-8 text-md-end table-button-controls">
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableAddBtn btn btn-primary rounded-1">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span class="ms-2">Create</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableRefreshBtn btn btn-outline-secondary rounded-1 disable-while-loading">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<span class="ms-2">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-inline-block">
|
||||
<button type="button" class="js-tableDeleteBtn btn btn-danger rounded-1" disabled>
|
||||
<i class="bi bi-trash3"></i>
|
||||
<span class="ms-2">Trash</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="js-tableBody table-responsive my-3 px-sm-3">
|
||||
<table id="subTable" class="table table-hover"></table>
|
||||
</div>
|
||||
<div class="js-tableControls row px-sm-3 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
|
||||
<ul class="pagination mb-0">
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-prev rounded-start-1">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<button type="button" class="page-link page-next rounded-end-1">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
|
||||
<label for="subTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
|
||||
<select name="subTablePageSizer" id="subTablePageSizer" class="corbz-select 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,10 +3,8 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from .views import IndexView, GuildsView, ChannelsView
|
||||
from .views import IndexView
|
||||
|
||||
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,19 +1,6 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.shortcuts import redirect
|
||||
from django.http import JsonResponse, HttpResponseNotFound, HttpResponseNotAllowed
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
from apps.home.models import Server, DiscordChannel
|
||||
from apps.api.serializers import ServerSerializer
|
||||
from apps.authentication.models import DiscordUser, ServerMember
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
@ -22,212 +9,3 @@ 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):
|
||||
"""
|
||||
Fetches the related guilds to the currently authenticated user from Discord.
|
||||
Will return a filtered list of the results, excluding servers where the
|
||||
user isn't an administrator or owner.
|
||||
|
||||
Valid servers will also be stored in the database for future reference,
|
||||
along-side the user-server relationship as a member.
|
||||
"""
|
||||
|
||||
async def get(self, request, *args, **kwargs):
|
||||
if not await is_user_authenticated(request.user):
|
||||
return redirect("/oauth2/login")
|
||||
|
||||
access_token = await get_user_access_token(request.user)
|
||||
guilds_data, status = await self._get_guilds_data(access_token)
|
||||
|
||||
# Send back the error data if status is bad
|
||||
if status != 200:
|
||||
return JsonResponse(guilds_data, status=status, safe=False)
|
||||
|
||||
cleaned_guilds_data = await self._clean_guilds_data(request.user, guilds_data)
|
||||
return JsonResponse(cleaned_guilds_data, safe=False)
|
||||
|
||||
async def _get_guilds_data(self, access_token: str) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Returns the raw guild data and a response status code
|
||||
from the Discord API.
|
||||
"""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
return response.json(), response.status_code
|
||||
|
||||
async def _clean_guilds_data(self, user: DiscordUser, guilds_data: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Returns a filtered copy of the given `guilds_data`, without the guilds
|
||||
where the given `user` is not an administrator or owner of.
|
||||
|
||||
Also, for each guild, creates/updates an object to represent it, and
|
||||
another to represent the user/guild relationship as a member.
|
||||
"""
|
||||
|
||||
cleaned_guilds_data = []
|
||||
|
||||
for item in guilds_data:
|
||||
cleaned_data = await self._setup_server(user, item)
|
||||
if cleaned_data:
|
||||
cleaned_guilds_data.append(cleaned_data)
|
||||
|
||||
return cleaned_guilds_data
|
||||
|
||||
async def _setup_server(self, user: DiscordUser, item: dict) -> dict[str] | None:
|
||||
"""
|
||||
Create or update a server and user's membership to said server.
|
||||
Returns the cleaned server data, or NoneType if the user isn't
|
||||
an admin or owner.
|
||||
"""
|
||||
|
||||
# Collect some commonly used server data
|
||||
server_id = item["id"]
|
||||
is_owner = item["owner"]
|
||||
permissions = item["permissions"]
|
||||
admin_perm = 1 << 3
|
||||
|
||||
# Skip servers where the user isn't an administrator or owner.
|
||||
# If an older member object exists, delete that too.
|
||||
if not ((int(permissions) & admin_perm) == admin_perm or is_owner):
|
||||
await self._try_delete_member(user, server_id)
|
||||
return
|
||||
|
||||
# Create or update an existing server matching the given ID
|
||||
server, created = await Server.objects.aupdate_or_create(
|
||||
id=server_id,
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"icon_hash": item["icon"]
|
||||
}
|
||||
)
|
||||
|
||||
# Create or update a member object linking the user to the server
|
||||
await ServerMember.objects.aupdate_or_create(
|
||||
user=user,
|
||||
server=server,
|
||||
defaults={
|
||||
"permissions": permissions,
|
||||
"is_owner": is_owner
|
||||
}
|
||||
)
|
||||
|
||||
return ServerSerializer(server).data
|
||||
|
||||
@staticmethod
|
||||
async def _try_delete_member(user: DiscordUser, server_id: int):
|
||||
"""
|
||||
Attempt to delete any existing server member linked to the given
|
||||
server id.
|
||||
"""
|
||||
|
||||
try:
|
||||
member = await ServerMember.objects.aget(user=user, server_id=server_id)
|
||||
await member.adelete()
|
||||
except ServerMember.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class ChannelsView(View):
|
||||
async def get(self, request, *args, **kwargs):
|
||||
if not await is_user_authenticated(request.user):
|
||||
return redirect("/oauth2/login")
|
||||
|
||||
guild_id = request.GET.get("guild")
|
||||
try: server = await Server.objects.aget(pk=guild_id)
|
||||
except Server.DoesNotExist: return HttpResponseNotFound("Server not found.")
|
||||
|
||||
if not await ServerMember.objects.filter(server=server, user=request.user).aexists():
|
||||
return HttpResponseNotAllowed("You aren't a member of this server.")
|
||||
|
||||
channels_data, status = await self._get_channel_data(guild_id)
|
||||
|
||||
# Not authorized means the bot isn't operational, we need to save that
|
||||
if status == 403:
|
||||
server.is_bot_operational = False
|
||||
await server.asave()
|
||||
|
||||
# Send back the error data if status is bad
|
||||
if status != 200:
|
||||
return JsonResponse(channels_data, status=status, safe=False)
|
||||
|
||||
# Because the status is 200, we know the Bot is functional, set that param
|
||||
server.is_bot_operational = True
|
||||
await server.asave()
|
||||
|
||||
cleaned_channels_data = await self._clean_channels_data(server, channels_data)
|
||||
await self._cleanup_dead_channels(server, cleaned_channels_data)
|
||||
|
||||
return JsonResponse(cleaned_channels_data, safe=False)
|
||||
|
||||
async def _get_channel_data(self, guild_id: int) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Returns the raw channel data and a response status code
|
||||
from the Discord API.
|
||||
"""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url=f"{settings.DISCORD_API_URL}/guilds/{guild_id}/channels",
|
||||
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
|
||||
)
|
||||
return response.json(), response.status_code
|
||||
|
||||
async def _clean_channels_data(self, server: Server, channels_data) -> list[dict]:
|
||||
"""
|
||||
Returns a sorted & cleaned list of channel data, also performs a
|
||||
database setup for each channel to be stored.
|
||||
"""
|
||||
|
||||
cleaned_channels_data = []
|
||||
|
||||
for item in channels_data:
|
||||
cleaned_data = await self._setup_channel(server, item)
|
||||
if cleaned_data:
|
||||
cleaned_channels_data.append(cleaned_data)
|
||||
|
||||
cleaned_channels_data.sort(key=lambda ch: ch.get("position"))
|
||||
return cleaned_channels_data
|
||||
|
||||
async def _setup_channel(self, server: Server, data: dict) -> dict:
|
||||
"""
|
||||
Create or update an instance of DiscordChannel representing the given
|
||||
`data` dictionary, returns the data or `NoneType` if `data['type'] != 0`.
|
||||
"""
|
||||
|
||||
# Type 0 = TextChannel, the only one we want
|
||||
if data.get("type") != 0:
|
||||
return
|
||||
|
||||
await DiscordChannel.objects.aupdate_or_create(
|
||||
id=data["id"],
|
||||
server=server,
|
||||
defaults={
|
||||
"name": data["name"],
|
||||
"is_nsfw": data["nsfw"]
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
async def _cleanup_dead_channels(self, server: Server, channel_data: list[dict]):
|
||||
"""
|
||||
Deletes any unused instances of `DiscordChannel` against the given server.
|
||||
"""
|
||||
|
||||
channel_ids = [item["id"] for item in channel_data]
|
||||
count, _ = await DiscordChannel.objects.filter(server=server).exclude(id__in=channel_ids).adelete()
|
||||
log.info("Deleted %s dead DiscordChannel object(s)", count)
|
||||
|
@ -74,35 +74,4 @@
|
||||
.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);
|
||||
}
|
||||
}
|
85
apps/static/css/home/index.css
Normal file
85
apps/static/css/home/index.css
Normal file
@ -0,0 +1,85 @@
|
||||
@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,10 +1,5 @@
|
||||
/* Select 2 */
|
||||
|
||||
/* Keep the select2 options visible above modals */
|
||||
.select2-container--open {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.select2-selection {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
font-size: 1rem !important;
|
||||
@ -91,16 +86,4 @@
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Invalid Select2 */
|
||||
|
||||
select.select-2.is-invalid + .select2 .select2-selection {
|
||||
color: var(--bs-form-invalid-color);
|
||||
padding-right: calc(1.5em + 0.75rem) !important;
|
||||
border-color: var(--bs-form-invalid-border-color) !important;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
|
||||
background-position: right calc(0.375em + 0.1875rem) center;
|
||||
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 434 KiB |
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