From adbea1e129de4faf7170fa66b9f7437e453a5953 Mon Sep 17 00:00:00 2001 From: dnns01 <mail@dnns01.de> Date: Thu, 7 Jan 2021 01:00:17 +0100 Subject: [PATCH] Create Webinterface around HaugeBot --- .idea/encodings.xml | 6 - .idea/haugebot.iml | 30 + .idea/misc.xml | 2 +- .idea/modules.xml | 2 +- .idea/pipimeter.iml | 10 - .idea/sqldialects.xml | 7 + .idea/vagrant.xml | 7 - haugebot/__init__.py | 0 haugebot/asgi.py | 16 + haugebot/settings.py | 125 ++ haugebot/urls.py | 29 + haugebot/wsgi.py | 16 + .../giveaway_cog.py | 2 +- hausgeist.py => haugebot_twitch/haugebot.py | 36 +- haugebot_twitch/info_cog.py | 37 + pipi_cog.py => haugebot_twitch/pipi_cog.py | 0 vote_cog.py => haugebot_twitch/vote_cog.py | 3 +- .../vote_redis.py | 0 haugebot_web/__init__.py | 0 haugebot_web/admin.py | 1 + haugebot_web/apps.py | 5 + haugebot_web/auth.py | 18 + haugebot_web/forms.py | 53 + haugebot_web/managers.py | 12 + haugebot_web/migrations/0001_initial.py | 109 + haugebot_web/migrations/__init__.py | 0 haugebot_web/models.py | 47 + haugebot_web/static/css/styles.css | 47 + haugebot_web/static/css/w3.css | 1749 +++++++++++++++++ haugebot_web/static/images/logo.png | Bin 0 -> 17377 bytes .../templates/color_select_option.html | 2 + haugebot_web/templates/form.html | 52 + haugebot_web/templates/home.html | 5 + haugebot_web/templates/layout.html | 36 + haugebot_web/tests.py | 1 + haugebot_web/twitch_api.py | 109 + haugebot_web/views.py | 88 + info.json | 25 - info_cog.py | 46 - manage.py | 22 + requirements.txt | 20 +- 41 files changed, 2658 insertions(+), 117 deletions(-) delete mode 100644 .idea/encodings.xml create mode 100644 .idea/haugebot.iml delete mode 100644 .idea/pipimeter.iml create mode 100644 .idea/sqldialects.xml delete mode 100644 .idea/vagrant.xml create mode 100644 haugebot/__init__.py create mode 100644 haugebot/asgi.py create mode 100644 haugebot/settings.py create mode 100644 haugebot/urls.py create mode 100644 haugebot/wsgi.py rename giveaway_cog.py => haugebot_twitch/giveaway_cog.py (99%) rename hausgeist.py => haugebot_twitch/haugebot.py (81%) create mode 100644 haugebot_twitch/info_cog.py rename pipi_cog.py => haugebot_twitch/pipi_cog.py (100%) rename vote_cog.py => haugebot_twitch/vote_cog.py (99%) rename vote_redis.py => haugebot_twitch/vote_redis.py (100%) create mode 100644 haugebot_web/__init__.py create mode 100644 haugebot_web/admin.py create mode 100644 haugebot_web/apps.py create mode 100644 haugebot_web/auth.py create mode 100644 haugebot_web/forms.py create mode 100644 haugebot_web/managers.py create mode 100644 haugebot_web/migrations/0001_initial.py create mode 100644 haugebot_web/migrations/__init__.py create mode 100644 haugebot_web/models.py create mode 100644 haugebot_web/static/css/styles.css create mode 100644 haugebot_web/static/css/w3.css create mode 100644 haugebot_web/static/images/logo.png create mode 100644 haugebot_web/templates/color_select_option.html create mode 100644 haugebot_web/templates/form.html create mode 100644 haugebot_web/templates/home.html create mode 100644 haugebot_web/templates/layout.html create mode 100644 haugebot_web/tests.py create mode 100644 haugebot_web/twitch_api.py create mode 100644 haugebot_web/views.py delete mode 100644 info.json delete mode 100644 info_cog.py create mode 100644 manage.py diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index e23123f..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="Encoding"> - <file url="file://$PROJECT_DIR$/info.json" charset="UTF-8" /> - </component> -</project> \ No newline at end of file diff --git a/.idea/haugebot.iml b/.idea/haugebot.iml new file mode 100644 index 0000000..6190690 --- /dev/null +++ b/.idea/haugebot.iml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="PYTHON_MODULE" version="4"> + <component name="FacetManager"> + <facet type="django" name="Django"> + <configuration> + <option name="rootFolder" value="$MODULE_DIR$" /> + <option name="settingsModule" value="haugebot/settings.py" /> + <option name="manageScript" value="$MODULE_DIR$/manage.py" /> + <option name="environment" value="<map/>" /> + <option name="doNotUseTestRunner" value="false" /> + <option name="trackFilePattern" value="migrations" /> + </configuration> + </facet> + </component> + <component name="NewModuleRootManager"> + <content url="file://$MODULE_DIR$"> + <excludeFolder url="file://$MODULE_DIR$/venv" /> + </content> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> + <component name="TemplatesService"> + <option name="TEMPLATE_CONFIGURATION" value="Django" /> + <option name="TEMPLATE_FOLDERS"> + <list> + <option value="$MODULE_DIR$/../haugebot\templates" /> + </list> + </option> + </component> +</module> \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index da49fd6..e32c50e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ <component name="JavaScriptSettings"> <option name="languageLevel" value="ES6" /> </component> - <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (TwitchHausGeist)" project-jdk-type="Python SDK" /> + <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (haugebot)" project-jdk-type="Python SDK" /> </project> \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index f7b4bc4..e9d8211 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ <project version="4"> <component name="ProjectModuleManager"> <modules> - <module fileurl="file://$PROJECT_DIR$/.idea/pipimeter.iml" filepath="$PROJECT_DIR$/.idea/pipimeter.iml" /> + <module fileurl="file://$PROJECT_DIR$/.idea/haugebot.iml" filepath="$PROJECT_DIR$/.idea/haugebot.iml" /> </modules> </component> </project> \ No newline at end of file diff --git a/.idea/pipimeter.iml b/.idea/pipimeter.iml deleted file mode 100644 index 6b70e77..0000000 --- a/.idea/pipimeter.iml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<module type="PYTHON_MODULE" version="4"> - <component name="NewModuleRootManager"> - <content url="file://$MODULE_DIR$"> - <excludeFolder url="file://$MODULE_DIR$/venv" /> - </content> - <orderEntry type="jdk" jdkName="Python 3.8 (TwitchHausGeist)" jdkType="Python SDK" /> - <orderEntry type="sourceFolder" forTests="false" /> - </component> -</module> \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..18091cb --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="SqlDialectMappings"> + <file url="file://$PROJECT_DIR$/haugebot_web/migrations/0001_initial.py" dialect="GenericSQL" /> + <file url="PROJECT" dialect="SQLite" /> + </component> +</project> \ No newline at end of file diff --git a/.idea/vagrant.xml b/.idea/vagrant.xml deleted file mode 100644 index a5aa786..0000000 --- a/.idea/vagrant.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="VagrantProjectSettings"> - <option name="instanceFolder" value="" /> - <option name="provider" value="" /> - </component> -</project> \ No newline at end of file diff --git a/haugebot/__init__.py b/haugebot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/haugebot/asgi.py b/haugebot/asgi.py new file mode 100644 index 0000000..abb3541 --- /dev/null +++ b/haugebot/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for haugebot project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haugebot.settings') + +application = get_asgi_application() diff --git a/haugebot/settings.py b/haugebot/settings.py new file mode 100644 index 0000000..04587d7 --- /dev/null +++ b/haugebot/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for haugebot project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("DJANGO_SECRET") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +AUTHENTICATION_BACKENDS = [ + 'haugebot_web.auth.TwitchAuthenticationBackend', +] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'haugebot_web', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'haugebot.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'haugebot.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/haugebot/urls.py b/haugebot/urls.py new file mode 100644 index 0000000..971b78f --- /dev/null +++ b/haugebot/urls.py @@ -0,0 +1,29 @@ +"""haugebot URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +from haugebot_web import views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', views.home, name="home"), + path('login/', views.login, name="login"), + path('logout/', views.logout, name="logout"), + path('login/redirect/', views.login_redirect, name="login_redirect"), + path('wusstest_du_schon/', views.wusstest_du_schon, name="wusstest_du_schon"), + path('wusstest_du_schon/remove/<int:id>', views.wusstest_du_schon_remove, name="wusstest_du_schon_remove"), +] diff --git a/haugebot/wsgi.py b/haugebot/wsgi.py new file mode 100644 index 0000000..cb12bbf --- /dev/null +++ b/haugebot/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for haugebot project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haugebot.settings') + +application = get_wsgi_application() diff --git a/giveaway_cog.py b/haugebot_twitch/giveaway_cog.py similarity index 99% rename from giveaway_cog.py rename to haugebot_twitch/giveaway_cog.py index 3c0d09f..31941bb 100644 --- a/giveaway_cog.py +++ b/haugebot_twitch/giveaway_cog.py @@ -5,7 +5,7 @@ from twitchio.ext import commands @commands.core.cog(name="GiveawayCog") -class GiveawayGog: +class GiveawayCog: def __init__(self, bot): self.bot = bot self.giveaway_enabled = False diff --git a/hausgeist.py b/haugebot_twitch/haugebot.py similarity index 81% rename from hausgeist.py rename to haugebot_twitch/haugebot.py index 16d6d01..0feb0e3 100644 --- a/hausgeist.py +++ b/haugebot_twitch/haugebot.py @@ -1,18 +1,21 @@ import asyncio -import logging import os +import sqlite3 from abc import ABC from dotenv import load_dotenv -from twitchio.dataclasses import Context, Message, Channel -from twitchio.ext import commands - -from giveaway_cog import GiveawayGog +from giveaway_cog import GiveawayCog +# from giveaway_cog import GiveawayGog from info_cog import InfoCog from pipi_cog import PipiCog +from twitchio.dataclasses import Context, Message, Channel +from twitchio.ext import commands from vote_cog import VoteCog -logging.basicConfig(level=logging.INFO, filename='hausgeist.log') +# from pipi_cog import PipiCog +# from vote_cog import VoteCog + +# logging.basicConfig(level=logging.INFO, filename='hausgeist.log') load_dotenv() IRC_TOKEN = os.getenv("IRC_TOKEN") @@ -33,14 +36,12 @@ class HaugeBot(commands.Bot, ABC): self.PREFIX = os.getenv("PREFIX") super().__init__(irc_token=IRC_TOKEN, prefix=PREFIX, nick=NICK, initial_channels=[CHANNEL], client_id=CLIENT_ID, client_secret=CLIENT_SECRET) - self.pipi_cog = PipiCog(self) - self.giveaway_cog = GiveawayGog(self) - self.vote_cog = VoteCog(self) self.info_cog = InfoCog(self) - self.add_cog(self.pipi_cog) - self.add_cog(self.giveaway_cog) - self.add_cog(self.vote_cog) + self.pipi_cog = PipiCog(self) + self.add_cog(GiveawayCog(self)) + self.add_cog(VoteCog(self)) self.add_cog(self.info_cog) + self.add_cog(self.pipi_cog) @staticmethod async def send_me(ctx, content, color): @@ -55,6 +56,7 @@ class HaugeBot(commands.Bot, ABC): async def event_ready(self): print('Logged in') + asyncio.create_task(self.info_cog.info_loop()) asyncio.create_task(self.pipi_cog.pipimeter_loop()) @@ -75,6 +77,16 @@ class HaugeBot(commands.Bot, ABC): async def stream(self): return await self.get_stream(self.CHANNEL) + @staticmethod + def get_setting(key): + conn = sqlite3.connect("db.sqlite3") + + c = conn.cursor() + c.execute('SELECT value from haugebot_web_setting where key = ?', (key,)) + value = c.fetchone()[0] + conn.close() + return value + bot = HaugeBot() diff --git a/haugebot_twitch/info_cog.py b/haugebot_twitch/info_cog.py new file mode 100644 index 0000000..2e6dc98 --- /dev/null +++ b/haugebot_twitch/info_cog.py @@ -0,0 +1,37 @@ +import asyncio +import random +import sqlite3 + +from twitchio.ext import commands + + +@commands.core.cog() +class InfoCog: + def __init__(self, bot): + self.bot = bot + + async def info_loop(self): + while True: + sleep_duration = int(self.bot.get_setting("WusstestDuSchonLoop")) + await asyncio.sleep(sleep_duration * 60) + + if await self.bot.stream(): + channel = self.bot.channel() + color = self.bot.get_setting("WusstestDuSchonColor") + prefix = self.bot.get_setting("WusstestDuSchonPrefix") + message = self.get_random_message(prefix) + await self.bot.send_me(channel, message, color) + + @staticmethod + def get_random_message(prefix): + conn = sqlite3.connect("db.sqlite3") + + c = conn.cursor() + c.execute('SELECT text, use_prefix from haugebot_web_wusstestduschon where active is true') + wusstestduschon = random.choice(c.fetchall()) + conn.close() + + if wusstestduschon[1] == 1: + return prefix.strip() + " " + wusstestduschon[0].strip() + else: + return wusstestduschon[0] diff --git a/pipi_cog.py b/haugebot_twitch/pipi_cog.py similarity index 100% rename from pipi_cog.py rename to haugebot_twitch/pipi_cog.py diff --git a/vote_cog.py b/haugebot_twitch/vote_cog.py similarity index 99% rename from vote_cog.py rename to haugebot_twitch/vote_cog.py index 3640aa6..21b45f2 100644 --- a/vote_cog.py +++ b/haugebot_twitch/vote_cog.py @@ -2,9 +2,8 @@ import asyncio import os import time -from twitchio.ext import commands - import vote_redis +from twitchio.ext import commands @commands.core.cog(name="VoteCog") diff --git a/vote_redis.py b/haugebot_twitch/vote_redis.py similarity index 100% rename from vote_redis.py rename to haugebot_twitch/vote_redis.py diff --git a/haugebot_web/__init__.py b/haugebot_web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/haugebot_web/admin.py b/haugebot_web/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/haugebot_web/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/haugebot_web/apps.py b/haugebot_web/apps.py new file mode 100644 index 0000000..39b5068 --- /dev/null +++ b/haugebot_web/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class HaugebotWebConfig(AppConfig): + name = 'haugebot_web' diff --git a/haugebot_web/auth.py b/haugebot_web/auth.py new file mode 100644 index 0000000..28b3e4b --- /dev/null +++ b/haugebot_web/auth.py @@ -0,0 +1,18 @@ +from django.contrib.auth.backends import BaseBackend + +from .models import TwitchUser + + +class TwitchAuthenticationBackend(BaseBackend): + def authenticated(self, request, user) -> TwitchUser: + find_user = TwitchUser.objects.filter(id=user['id']) + if len(find_user) == 0: + TwitchUser.objects.create_new_twitch_user(user) + return self.authenticate(request, user) + return find_user + + def get_user(self, user_id): + try: + return TwitchUser.objects.get(pk=user_id) + except TwitchUser.DoesNotExist: + return None diff --git a/haugebot_web/forms.py b/haugebot_web/forms.py new file mode 100644 index 0000000..b34be0d --- /dev/null +++ b/haugebot_web/forms.py @@ -0,0 +1,53 @@ +from django import forms + +from .models import Setting, TwitchColor + + +class BaseForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(BaseForm, self).__init__(*args, **kwargs) + for field_name, field in self.fields.items(): + if type(field) is forms.fields.BooleanField: + field.widget.attrs['class'] = ' w3-check ' + field.label_suffix = "" + else: + field.widget.attrs['class'] = ' w3-input ' + field.widget.attrs['placeholder'] = field.label + + +class WusstestDuSchonSettingsForm(forms.Form): + prefix_field = forms.CharField(max_length=50, initial=Setting.objects.get(key="WusstestDuSchonPrefix").value, + label="Präfix") + loop_field = forms.IntegerField(initial=Setting.objects.get(key="WusstestDuSchonLoop").value, + label="Pause (in Minuten)") + color_field = forms.ChoiceField(choices=[(color.color, color.display_name) for color in TwitchColor.objects.all()], + label="Text Farbe") + + def __init__(self, *args, **kwargs): + super(WusstestDuSchonSettingsForm, self).__init__(*args, **kwargs) + + self.fields["prefix_field"].initial = Setting.objects.get(key="WusstestDuSchonPrefix").value + self.fields["loop_field"].initial = Setting.objects.get(key="WusstestDuSchonLoop").value + self.fields["color_field"].initial = TwitchColor.objects.get( + twitch_name=Setting.objects.get(key="WusstestDuSchonColor").value).color + + for field_name, field in self.fields.items(): + if type(field) is forms.fields.BooleanField: + field.widget.attrs['class'] = ' w3-check ' + field.label_suffix = "" + else: + field.widget.attrs['class'] = ' w3-input ' + field.widget.attrs['placeholder'] = field.label + + def save(self): + prefix = Setting.objects.get(key="WusstestDuSchonPrefix") + prefix.value = self.cleaned_data["prefix_field"] + prefix.save() + + loop = Setting.objects.get(key="WusstestDuSchonLoop") + loop.value = self.cleaned_data["loop_field"] + loop.save() + + color = Setting.objects.get(key="WusstestDuSchonColor") + color.value = TwitchColor.objects.get(color=self.cleaned_data["color_field"]).twitch_name + color.save() diff --git a/haugebot_web/managers.py b/haugebot_web/managers.py new file mode 100644 index 0000000..d082c72 --- /dev/null +++ b/haugebot_web/managers.py @@ -0,0 +1,12 @@ +from django.contrib.auth import models + + +class TwitchUserManager(models.UserManager): + def create_new_twitch_user(self, user): + new_user = self.create( + id=user['id'], + login=user['login'], + access_token=user['access_token'], + refresh_token=user['refresh_token'] + ) + return new_user diff --git a/haugebot_web/migrations/0001_initial.py b/haugebot_web/migrations/0001_initial.py new file mode 100644 index 0000000..0dead91 --- /dev/null +++ b/haugebot_web/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 3.1.5 on 2021-01-05 12:27 + +from django.db import migrations, models + +import haugebot_web.managers + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Setting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=50)), + ('value', models.CharField(max_length=50)), + ], + ), + migrations.CreateModel( + name='TwitchColor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('twitch_name', models.CharField(max_length=20)), + ('display_name', models.CharField(max_length=20)), + ('color', models.CharField(max_length=7)), + ], + ), + migrations.CreateModel( + name='TwitchUser', + fields=[ + ('id', models.BigIntegerField(primary_key=True, serialize=False)), + ('login', models.CharField(max_length=50)), + ('access_token', models.CharField(max_length=50)), + ('refresh_token', models.CharField(max_length=50)), + ('last_login', models.DateTimeField(null=True)), + ], + managers=[ + ('objects', haugebot_web.managers.TwitchUserManager()), + ], + ), + migrations.CreateModel( + name='WusstestDuSchon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('advertised_command', models.CharField(max_length=20)), + ('text', models.TextField(max_length=450)), + ('use_prefix', models.BooleanField(default=True, verbose_name='Präfix verwenden')), + ('active', models.BooleanField(default=True, verbose_name='Aktiv')), + ], + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_setting (key, value) VALUES('WusstestDuSchonPrefix', 'Psssst... wusstest du eigentlich schon,')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_setting (key, value) VALUES('WusstestDuSchonLoop', '10')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_setting (key, value) VALUES('WusstestDuSchonColor', 'HotPink')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (1, 'Red', 'Rot', '#ff0000')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (2, 'Blue', 'Blau', '#0000ff')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (3, 'Green', 'Grün', '#008000')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (4, 'FireBrick', 'Backstein', '#b22222')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (5, 'Coral', 'Korallenrot', '#ff7f50')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (6, 'YellowGreen', 'Gelbliches Grün', '#9acd32')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (7, 'OrangeRed', 'Orangenrot', '#ff4500')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (8, 'SeaGreen', 'Meeresgrün', '#2e8b57')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (9, 'GoldenRod', 'Goldrute', '#daa520')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (10, 'Chocolate', 'Schokoladenbraun', '#d2691e')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (11, 'CadetBlue', 'Marineblau', '#5f9ea0')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (12, 'DodgerBlue', 'Dodgerblau', '#1e90ff')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (13, 'HotPink', 'Leuchtendes Rosa', '#ff69b4')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (14, 'BlueViolet', 'Blauviolett', '#8a2be2')" + ), + migrations.RunSQL( + "INSERT INTO haugebot_web_twitchcolor (id, twitch_name, display_name, color) VALUES (15, 'SpringGreen', 'Frühlingsgrün', '#00ff7f')" + ), + ] diff --git a/haugebot_web/migrations/__init__.py b/haugebot_web/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/haugebot_web/models.py b/haugebot_web/models.py new file mode 100644 index 0000000..a65a857 --- /dev/null +++ b/haugebot_web/models.py @@ -0,0 +1,47 @@ +import os + +from django.db import models + +from haugebot_web import twitch_api +from .managers import TwitchUserManager + + +class Setting(models.Model): + key = models.CharField(max_length=50) + value = models.CharField(max_length=50) + + +class TwitchColor(models.Model): + twitch_name = models.CharField(max_length=20) + display_name = models.CharField(max_length=20) + color = models.CharField(max_length=7) + + +class WusstestDuSchon(models.Model): + advertised_command = models.CharField(max_length=20) + text = models.TextField(max_length=450) + use_prefix = models.BooleanField(default=True, verbose_name="Präfix verwenden") + active = models.BooleanField(default=True, verbose_name="Aktiv") + + +class TwitchUser(models.Model): + objects = TwitchUserManager() + + id = models.BigIntegerField(primary_key=True) + login = models.CharField(max_length=50) + access_token = models.CharField(max_length=50) + refresh_token = models.CharField(max_length=50) + last_login = models.DateTimeField(null=True) + + def update_tokens(self, access_token, refresh_token): + self.access_token = access_token + self.refresh_token = refresh_token + self.save() + + def is_authenticated(self): + broadcaster_id = os.getenv("BROADCASTER_ID") + try: + broadcaster = TwitchUser.objects.get(pk=broadcaster_id) + return twitch_api.is_mod(self, broadcaster) + except TwitchUser.DoesNotExist: + return False diff --git a/haugebot_web/static/css/styles.css b/haugebot_web/static/css/styles.css new file mode 100644 index 0000000..ba369ff --- /dev/null +++ b/haugebot_web/static/css/styles.css @@ -0,0 +1,47 @@ +#content { + margin-left: 200px; +} + +.w3-haugedark { + background: #222222; + color: #fca4ba; +} + +.w3-haugepink { + background: #fca4ba; + color: #000e47; +} + +.w3-haugepinkdark { + background: #f65656; +} + +.w3-haugeblue { + background: #000e47; +} + +.w3-haugepink:hover { + background: #f65656 !important; + color: #000e47; +} + +#logo { + max-width: 100%; +} + +.w3-form-container { + margin-right: 50px; +} + +.w3-display-topright .w3-button { + margin-right: 1em; +} + +.w3-card { + margin-bottom: 20px; +} + +textarea { + max-height: 62px; + resize: none; +} \ No newline at end of file diff --git a/haugebot_web/static/css/w3.css b/haugebot_web/static/css/w3.css new file mode 100644 index 0000000..5bc5502 --- /dev/null +++ b/haugebot_web/static/css/w3.css @@ -0,0 +1,1749 @@ +/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ +html { + box-sizing: border-box +} + +*, *:before, *:after { + box-sizing: inherit +} + +/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ +html { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100% +} + +body { + margin: 0 +} + +article, aside, details, figcaption, figure, footer, header, main, menu, nav, section { + display: block +} + +summary { + display: list-item +} + +audio, canvas, progress, video { + display: inline-block +} + +progress { + vertical-align: baseline +} + +audio:not([controls]) { + display: none; + height: 0 +} + +[hidden], template { + display: none +} + +a { + background-color: transparent +} + +a:active, a:hover { + outline-width: 0 +} + +abbr[title] { + border-bottom: none; + text-decoration: underline; + text-decoration: underline dotted +} + +b, strong { + font-weight: bolder +} + +dfn { + font-style: italic +} + +mark { + background: #ff0; + color: #000 +} + +small { + font-size: 80% +} + +sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline +} + +sub { + bottom: -0.25em +} + +sup { + top: -0.5em +} + +figure { + margin: 1em 40px +} + +img { + border-style: none +} + +code, kbd, pre, samp { + font-family: monospace, monospace; + font-size: 1em +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible +} + +button, input, select, textarea, optgroup { + font: inherit; + margin: 0 +} + +optgroup { + font-weight: bold +} + +button, input { + overflow: visible +} + +button, select { + text-transform: none +} + +button, [type=button], [type=reset], [type=submit] { + -webkit-appearance: button +} + +button::-moz-focus-inner, [type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner { + border-style: none; + padding: 0 +} + +button:-moz-focusring, [type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring { + outline: 1px dotted ButtonText +} + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: .35em .625em .75em +} + +legend { + color: inherit; + display: table; + max-width: 100%; + padding: 0; + white-space: normal +} + +textarea { + overflow: auto +} + +[type=checkbox], [type=radio] { + padding: 0 +} + +[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button { + height: auto +} + +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px +} + +[type=search]::-webkit-search-decoration { + -webkit-appearance: none +} + +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit +} + +/* End extract */ +html, body { + font-family: Verdana, sans-serif; + font-size: 15px; + line-height: 1.5 +} + +html { + overflow-x: hidden +} + +h1 { + font-size: 36px +} + +h2 { + font-size: 30px +} + +h3 { + font-size: 24px +} + +h4 { + font-size: 20px +} + +h5 { + font-size: 18px +} + +h6 { + font-size: 16px +} + +.w3-serif { + font-family: serif +} + +.w3-sans-serif { + font-family: sans-serif +} + +.w3-cursive { + font-family: cursive +} + +.w3-monospace { + font-family: monospace +} + +h1, h2, h3, h4, h5, h6 { + font-family: "Segoe UI", Arial, sans-serif; + font-weight: 400; + margin: 10px 0 +} + +.w3-wide { + letter-spacing: 4px +} + +hr { + border: 0; + border-top: 1px solid #eee; + margin: 20px 0 +} + +.w3-image { + max-width: 100%; + height: auto +} + +img { + vertical-align: middle +} + +a { + color: inherit +} + +.w3-table, .w3-table-all { + border-collapse: collapse; + border-spacing: 0; + width: 100%; + display: table +} + +.w3-table-all { + border: 1px solid #ccc +} + +.w3-bordered tr, .w3-table-all tr { + border-bottom: 1px solid #ddd +} + +.w3-striped tbody tr:nth-child(even) { + background-color: #f1f1f1 +} + +.w3-table-all tr:nth-child(odd) { + background-color: #fff +} + +.w3-table-all tr:nth-child(even) { + background-color: #f1f1f1 +} + +.w3-hoverable tbody tr:hover, .w3-ul.w3-hoverable li:hover { + background-color: #ccc +} + +.w3-centered tr th, .w3-centered tr td { + text-align: center +} + +.w3-table td, .w3-table th, .w3-table-all td, .w3-table-all th { + padding: 8px 8px; + display: table-cell; + text-align: left; + vertical-align: top +} + +.w3-table th:first-child, .w3-table td:first-child, .w3-table-all th:first-child, .w3-table-all td:first-child { + padding-left: 16px +} + +.w3-btn, .w3-button { + border: none; + display: inline-block; + padding: 8px 16px; + vertical-align: middle; + overflow: hidden; + text-decoration: none; + color: inherit; + background-color: inherit; + text-align: center; + cursor: pointer; + white-space: nowrap +} + +.w3-btn:hover { + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19) +} + +.w3-btn, .w3-button { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none +} + +.w3-disabled, .w3-btn:disabled, .w3-button:disabled { + cursor: not-allowed; + opacity: 0.3 +} + +.w3-disabled *, :disabled * { + pointer-events: none +} + +.w3-btn.w3-disabled:hover, .w3-btn:disabled:hover { + box-shadow: none +} + +.w3-badge, .w3-tag { + background-color: #000; + color: #fff; + display: inline-block; + padding-left: 8px; + padding-right: 8px; + text-align: center +} + +.w3-badge { + border-radius: 50% +} + +.w3-ul { + list-style-type: none; + padding: 0; + margin: 0 +} + +.w3-ul li { + padding: 8px 16px; + border-bottom: 1px solid #ddd +} + +.w3-ul li:last-child { + border-bottom: none +} + +.w3-tooltip, .w3-display-container { + position: relative +} + +.w3-tooltip .w3-text { + display: none +} + +.w3-tooltip:hover .w3-text { + display: inline-block +} + +.w3-ripple:active { + opacity: 0.5 +} + +.w3-ripple { + transition: opacity 0s +} + +.w3-input { + padding: 8px; + display: block; + border: none; + border-bottom: 1px solid #ccc; + width: 100% +} + +.w3-select { + padding: 9px 0; + width: 100%; + border: none; + border-bottom: 1px solid #ccc +} + +.w3-dropdown-click, .w3-dropdown-hover { + position: relative; + display: inline-block; + cursor: pointer +} + +.w3-dropdown-hover:hover .w3-dropdown-content { + display: block +} + +.w3-dropdown-hover:first-child, .w3-dropdown-click:hover { + background-color: #ccc; + color: #000 +} + +.w3-dropdown-hover:hover > .w3-button:first-child, .w3-dropdown-click:hover > .w3-button:first-child { + background-color: #ccc; + color: #000 +} + +.w3-dropdown-content { + cursor: auto; + color: #000; + background-color: #fff; + display: none; + position: absolute; + min-width: 160px; + margin: 0; + padding: 0; + z-index: 1 +} + +.w3-check, .w3-radio { + width: 24px; + height: 24px; + position: relative; + top: 6px +} + +.w3-sidebar { + height: 100%; + width: 200px; + background-color: #fff; + position: fixed !important; + z-index: 1; + overflow: auto +} + +.w3-bar-block .w3-dropdown-hover, .w3-bar-block .w3-dropdown-click { + width: 100% +} + +.w3-bar-block .w3-dropdown-hover .w3-dropdown-content, .w3-bar-block .w3-dropdown-click .w3-dropdown-content { + min-width: 100% +} + +.w3-bar-block .w3-dropdown-hover .w3-button, .w3-bar-block .w3-dropdown-click .w3-button { + width: 100%; + text-align: left; + padding: 8px 16px +} + +.w3-main, #main { + transition: margin-left .4s +} + +.w3-modal { + z-index: 3; + display: none; + padding-top: 100px; + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0, 0, 0); + background-color: rgba(0, 0, 0, 0.4) +} + +.w3-modal-content { + margin: auto; + background-color: #fff; + position: relative; + padding: 0; + outline: 0; + width: 600px +} + +.w3-bar { + width: 100%; + overflow: hidden +} + +.w3-center .w3-bar { + display: inline-block; + width: auto +} + +.w3-bar .w3-bar-item { + padding: 8px 16px; + float: left; + width: auto; + border: none; + display: block; + outline: 0 +} + +.w3-bar .w3-dropdown-hover, .w3-bar .w3-dropdown-click { + position: static; + float: left +} + +.w3-bar .w3-button { + white-space: normal +} + +.w3-bar-block .w3-bar-item { + width: 100%; + display: block; + padding: 8px 16px; + text-align: left; + border: none; + white-space: normal; + float: none; + outline: 0 +} + +.w3-bar-block.w3-center .w3-bar-item { + text-align: center +} + +.w3-block { + display: block; + width: 100% +} + +.w3-responsive { + display: block; + overflow-x: auto +} + +.w3-container:after, .w3-container:before, .w3-panel:after, .w3-panel:before, .w3-row:after, .w3-row:before, .w3-row-padding:after, .w3-row-padding:before, +.w3-cell-row:before, .w3-cell-row:after, .w3-clear:after, .w3-clear:before, .w3-bar:before, .w3-bar:after { + content: ""; + display: table; + clear: both +} + +.w3-col, .w3-half, .w3-third, .w3-twothird, .w3-threequarter, .w3-quarter { + float: left; + width: 100% +} + +.w3-col.s1 { + width: 8.33333% +} + +.w3-col.s2 { + width: 16.66666% +} + +.w3-col.s3 { + width: 24.99999% +} + +.w3-col.s4 { + width: 33.33333% +} + +.w3-col.s5 { + width: 41.66666% +} + +.w3-col.s6 { + width: 49.99999% +} + +.w3-col.s7 { + width: 58.33333% +} + +.w3-col.s8 { + width: 66.66666% +} + +.w3-col.s9 { + width: 74.99999% +} + +.w3-col.s10 { + width: 83.33333% +} + +.w3-col.s11 { + width: 91.66666% +} + +.w3-col.s12 { + width: 99.99999% +} + +@media (min-width: 601px) { + .w3-col.m1 { + width: 8.33333% + } + + .w3-col.m2 { + width: 16.66666% + } + + .w3-col.m3, .w3-quarter { + width: 24.99999% + } + + .w3-col.m4, .w3-third { + width: 33.33333% + } + + .w3-col.m5 { + width: 41.66666% + } + + .w3-col.m6, .w3-half { + width: 49.99999% + } + + .w3-col.m7 { + width: 58.33333% + } + + .w3-col.m8, .w3-twothird { + width: 66.66666% + } + + .w3-col.m9, .w3-threequarter { + width: 74.99999% + } + + .w3-col.m10 { + width: 83.33333% + } + + .w3-col.m11 { + width: 91.66666% + } + + .w3-col.m12 { + width: 99.99999% + } +} + +@media (min-width: 993px) { + .w3-col.l1 { + width: 8.33333% + } + + .w3-col.l2 { + width: 16.66666% + } + + .w3-col.l3 { + width: 24.99999% + } + + .w3-col.l4 { + width: 33.33333% + } + + .w3-col.l5 { + width: 41.66666% + } + + .w3-col.l6 { + width: 49.99999% + } + + .w3-col.l7 { + width: 58.33333% + } + + .w3-col.l8 { + width: 66.66666% + } + + .w3-col.l9 { + width: 74.99999% + } + + .w3-col.l10 { + width: 83.33333% + } + + .w3-col.l11 { + width: 91.66666% + } + + .w3-col.l12 { + width: 99.99999% + } +} + +.w3-rest { + overflow: hidden +} + +.w3-stretch { + margin-left: -16px; + margin-right: -16px +} + +.w3-content, .w3-auto { + margin-left: auto; + margin-right: auto +} + +.w3-content { + max-width: 980px +} + +.w3-auto { + max-width: 1140px +} + +.w3-cell-row { + display: table; + width: 100% +} + +.w3-cell { + display: table-cell +} + +.w3-cell-top { + vertical-align: top +} + +.w3-cell-middle { + vertical-align: middle +} + +.w3-cell-bottom { + vertical-align: bottom +} + +.w3-hide { + display: none !important +} + +.w3-show-block, .w3-show { + display: block !important +} + +.w3-show-inline-block { + display: inline-block !important +} + +@media (max-width: 1205px) { + .w3-auto { + max-width: 95% + } +} + +@media (max-width: 600px) { + .w3-modal-content { + margin: 0 10px; + width: auto !important + } + + .w3-modal { + padding-top: 30px + } + + .w3-dropdown-hover.w3-mobile .w3-dropdown-content, .w3-dropdown-click.w3-mobile .w3-dropdown-content { + position: relative + } + + .w3-hide-small { + display: none !important + } + + .w3-mobile { + display: block; + width: 100% !important + } + + .w3-bar-item.w3-mobile, .w3-dropdown-hover.w3-mobile, .w3-dropdown-click.w3-mobile { + text-align: center + } + + .w3-dropdown-hover.w3-mobile, .w3-dropdown-hover.w3-mobile .w3-btn, .w3-dropdown-hover.w3-mobile .w3-button, .w3-dropdown-click.w3-mobile, .w3-dropdown-click.w3-mobile .w3-btn, .w3-dropdown-click.w3-mobile .w3-button { + width: 100% + } +} + +@media (max-width: 768px) { + .w3-modal-content { + width: 500px + } + + .w3-modal { + padding-top: 50px + } +} + +@media (min-width: 993px) { + .w3-modal-content { + width: 900px + } + + .w3-hide-large { + display: none !important + } + + .w3-sidebar.w3-collapse { + display: block !important + } +} + +@media (max-width: 992px) and (min-width: 601px) { + .w3-hide-medium { + display: none !important + } +} + +@media (max-width: 992px) { + .w3-sidebar.w3-collapse { + display: none + } + + .w3-main { + margin-left: 0 !important; + margin-right: 0 !important + } + + .w3-auto { + max-width: 100% + } +} + +.w3-top, .w3-bottom { + position: fixed; + width: 100%; + z-index: 1 +} + +.w3-top { + top: 0 +} + +.w3-bottom { + bottom: 0 +} + +.w3-overlay { + position: fixed; + display: none; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2 +} + +.w3-display-topleft { + position: absolute; + left: 0; + top: 0 +} + +.w3-display-topright { + position: absolute; + right: 0; + top: 0 +} + +.w3-display-bottomleft { + position: absolute; + left: 0; + bottom: 0 +} + +.w3-display-bottomright { + position: absolute; + right: 0; + bottom: 0 +} + +.w3-display-middle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%) +} + +.w3-display-left { + position: absolute; + top: 50%; + left: 0%; + transform: translate(0%, -50%); + -ms-transform: translate(-0%, -50%) +} + +.w3-display-right { + position: absolute; + top: 50%; + right: 0%; + transform: translate(0%, -50%); + -ms-transform: translate(0%, -50%) +} + +.w3-display-topmiddle { + position: absolute; + left: 50%; + top: 0; + transform: translate(-50%, 0%); + -ms-transform: translate(-50%, 0%) +} + +.w3-display-bottommiddle { + position: absolute; + left: 50%; + bottom: 0; + transform: translate(-50%, 0%); + -ms-transform: translate(-50%, 0%) +} + +.w3-display-container:hover .w3-display-hover { + display: block +} + +.w3-display-container:hover span.w3-display-hover { + display: inline-block +} + +.w3-display-hover { + display: none +} + +.w3-display-position { + position: absolute +} + +.w3-circle { + border-radius: 50% +} + +.w3-round-small { + border-radius: 2px +} + +.w3-round, .w3-round-medium { + border-radius: 4px +} + +.w3-round-large { + border-radius: 8px +} + +.w3-round-xlarge { + border-radius: 16px +} + +.w3-round-xxlarge { + border-radius: 32px +} + +.w3-row-padding, .w3-row-padding > .w3-half, .w3-row-padding > .w3-third, .w3-row-padding > .w3-twothird, .w3-row-padding > .w3-threequarter, .w3-row-padding > .w3-quarter, .w3-row-padding > .w3-col { + padding: 0 8px +} + +.w3-container, .w3-panel { + padding: 0.01em 16px +} + +.w3-panel { + margin-top: 16px; + margin-bottom: 16px +} + +.w3-code, .w3-codespan { + font-family: Consolas, "courier new"; + font-size: 16px +} + +.w3-code { + width: auto; + background-color: #fff; + padding: 8px 12px; + border-left: 4px solid #4CAF50; + word-wrap: break-word +} + +.w3-codespan { + color: crimson; + background-color: #f1f1f1; + padding-left: 4px; + padding-right: 4px; + font-size: 110% +} + +.w3-card, .w3-card-2 { + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12) +} + +.w3-card-4, .w3-hover-shadow:hover { + box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.2), 0 4px 20px 0 rgba(0, 0, 0, 0.19) +} + +.w3-spin { + animation: w3-spin 2s infinite linear +} + +@keyframes w3-spin { + 0% { + transform: rotate(0deg) + } + 100% { + transform: rotate(359deg) + } +} + +.w3-animate-fading { + animation: fading 10s infinite +} + +@keyframes fading { + 0% { + opacity: 0 + } + 50% { + opacity: 1 + } + 100% { + opacity: 0 + } +} + +.w3-animate-opacity { + animation: opac 0.8s +} + +@keyframes opac { + from { + opacity: 0 + } + to { + opacity: 1 + } +} + +.w3-animate-top { + position: relative; + animation: animatetop 0.4s +} + +@keyframes animatetop { + from { + top: -300px; + opacity: 0 + } + to { + top: 0; + opacity: 1 + } +} + +.w3-animate-left { + position: relative; + animation: animateleft 0.4s +} + +@keyframes animateleft { + from { + left: -300px; + opacity: 0 + } + to { + left: 0; + opacity: 1 + } +} + +.w3-animate-right { + position: relative; + animation: animateright 0.4s +} + +@keyframes animateright { + from { + right: -300px; + opacity: 0 + } + to { + right: 0; + opacity: 1 + } +} + +.w3-animate-bottom { + position: relative; + animation: animatebottom 0.4s +} + +@keyframes animatebottom { + from { + bottom: -300px; + opacity: 0 + } + to { + bottom: 0; + opacity: 1 + } +} + +.w3-animate-zoom { + animation: animatezoom 0.6s +} + +@keyframes animatezoom { + from { + transform: scale(0) + } + to { + transform: scale(1) + } +} + +.w3-animate-input { + transition: width 0.4s ease-in-out +} + +.w3-animate-input:focus { + width: 100% !important +} + +.w3-opacity, .w3-hover-opacity:hover { + opacity: 0.60 +} + +.w3-opacity-off, .w3-hover-opacity-off:hover { + opacity: 1 +} + +.w3-opacity-max { + opacity: 0.25 +} + +.w3-opacity-min { + opacity: 0.75 +} + +.w3-greyscale-max, .w3-grayscale-max, .w3-hover-greyscale:hover, .w3-hover-grayscale:hover { + filter: grayscale(100%) +} + +.w3-greyscale, .w3-grayscale { + filter: grayscale(75%) +} + +.w3-greyscale-min, .w3-grayscale-min { + filter: grayscale(50%) +} + +.w3-sepia { + filter: sepia(75%) +} + +.w3-sepia-max, .w3-hover-sepia:hover { + filter: sepia(100%) +} + +.w3-sepia-min { + filter: sepia(50%) +} + +.w3-tiny { + font-size: 10px !important +} + +.w3-small { + font-size: 12px !important +} + +.w3-medium { + font-size: 15px !important +} + +.w3-large { + font-size: 18px !important +} + +.w3-xlarge { + font-size: 24px !important +} + +.w3-xxlarge { + font-size: 36px !important +} + +.w3-xxxlarge { + font-size: 48px !important +} + +.w3-jumbo { + font-size: 64px !important +} + +.w3-left-align { + text-align: left !important +} + +.w3-right-align { + text-align: right !important +} + +.w3-justify { + text-align: justify !important +} + +.w3-center { + text-align: center !important +} + +.w3-border-0 { + border: 0 !important +} + +.w3-border { + border: 1px solid #ccc !important +} + +.w3-border-top { + border-top: 1px solid #ccc !important +} + +.w3-border-bottom { + border-bottom: 1px solid #ccc !important +} + +.w3-border-left { + border-left: 1px solid #ccc !important +} + +.w3-border-right { + border-right: 1px solid #ccc !important +} + +.w3-topbar { + border-top: 6px solid #ccc !important +} + +.w3-bottombar { + border-bottom: 6px solid #ccc !important +} + +.w3-leftbar { + border-left: 6px solid #ccc !important +} + +.w3-rightbar { + border-right: 6px solid #ccc !important +} + +.w3-section, .w3-code { + margin-top: 16px !important; + margin-bottom: 16px !important +} + +.w3-margin { + margin: 16px !important +} + +.w3-margin-top { + margin-top: 16px !important +} + +.w3-margin-bottom { + margin-bottom: 16px !important +} + +.w3-margin-left { + margin-left: 16px !important +} + +.w3-margin-right { + margin-right: 16px !important +} + +.w3-padding-small { + padding: 4px 8px !important +} + +.w3-padding { + padding: 8px 16px !important +} + +.w3-padding-large { + padding: 12px 24px !important +} + +.w3-padding-16 { + padding-top: 16px !important; + padding-bottom: 16px !important +} + +.w3-padding-24 { + padding-top: 24px !important; + padding-bottom: 24px !important +} + +.w3-padding-32 { + padding-top: 32px !important; + padding-bottom: 32px !important +} + +.w3-padding-48 { + padding-top: 48px !important; + padding-bottom: 48px !important +} + +.w3-padding-64 { + padding-top: 64px !important; + padding-bottom: 64px !important +} + +.w3-padding-top-64 { + padding-top: 64px !important +} + +.w3-padding-top-48 { + padding-top: 48px !important +} + +.w3-padding-top-32 { + padding-top: 32px !important +} + +.w3-padding-top-24 { + padding-top: 24px !important +} + +.w3-left { + float: left !important +} + +.w3-right { + float: right !important +} + +.w3-button:hover { + color: #000 !important; + background-color: #ccc !important +} + +.w3-transparent, .w3-hover-none:hover { + background-color: transparent !important +} + +.w3-hover-none:hover { + box-shadow: none !important +} + +/* Colors */ +.w3-amber, .w3-hover-amber:hover { + color: #000 !important; + background-color: #ffc107 !important +} + +.w3-aqua, .w3-hover-aqua:hover { + color: #000 !important; + background-color: #00ffff !important +} + +.w3-blue, .w3-hover-blue:hover { + color: #fff !important; + background-color: #2196F3 !important +} + +.w3-light-blue, .w3-hover-light-blue:hover { + color: #000 !important; + background-color: #87CEEB !important +} + +.w3-brown, .w3-hover-brown:hover { + color: #fff !important; + background-color: #795548 !important +} + +.w3-cyan, .w3-hover-cyan:hover { + color: #000 !important; + background-color: #00bcd4 !important +} + +.w3-blue-grey, .w3-hover-blue-grey:hover, .w3-blue-gray, .w3-hover-blue-gray:hover { + color: #fff !important; + background-color: #607d8b !important +} + +.w3-green, .w3-hover-green:hover { + color: #fff !important; + background-color: #4CAF50 !important +} + +.w3-light-green, .w3-hover-light-green:hover { + color: #000 !important; + background-color: #8bc34a !important +} + +.w3-indigo, .w3-hover-indigo:hover { + color: #fff !important; + background-color: #3f51b5 !important +} + +.w3-khaki, .w3-hover-khaki:hover { + color: #000 !important; + background-color: #f0e68c !important +} + +.w3-lime, .w3-hover-lime:hover { + color: #000 !important; + background-color: #cddc39 !important +} + +.w3-orange, .w3-hover-orange:hover { + color: #000 !important; + background-color: #ff9800 !important +} + +.w3-deep-orange, .w3-hover-deep-orange:hover { + color: #fff !important; + background-color: #ff5722 !important +} + +.w3-pink, .w3-hover-pink:hover { + color: #fff !important; + background-color: #e91e63 !important +} + +.w3-purple, .w3-hover-purple:hover { + color: #fff !important; + background-color: #9c27b0 !important +} + +.w3-deep-purple, .w3-hover-deep-purple:hover { + color: #fff !important; + background-color: #673ab7 !important +} + +.w3-red, .w3-hover-red:hover { + color: #fff !important; + background-color: #f44336 !important +} + +.w3-sand, .w3-hover-sand:hover { + color: #000 !important; + background-color: #fdf5e6 !important +} + +.w3-teal, .w3-hover-teal:hover { + color: #fff !important; + background-color: #009688 !important +} + +.w3-yellow, .w3-hover-yellow:hover { + color: #000 !important; + background-color: #ffeb3b !important +} + +.w3-white, .w3-hover-white:hover { + color: #000 !important; + background-color: #fff !important +} + +.w3-black, .w3-hover-black:hover { + color: #fff !important; + background-color: #000 !important +} + +.w3-grey, .w3-hover-grey:hover, .w3-gray, .w3-hover-gray:hover { + color: #000 !important; + background-color: #9e9e9e !important +} + +.w3-light-grey, .w3-hover-light-grey:hover, .w3-light-gray, .w3-hover-light-gray:hover { + color: #000 !important; + background-color: #f1f1f1 !important +} + +.w3-dark-grey, .w3-hover-dark-grey:hover, .w3-dark-gray, .w3-hover-dark-gray:hover { + color: #fff !important; + background-color: #616161 !important +} + +.w3-pale-red, .w3-hover-pale-red:hover { + color: #000 !important; + background-color: #ffdddd !important +} + +.w3-pale-green, .w3-hover-pale-green:hover { + color: #000 !important; + background-color: #ddffdd !important +} + +.w3-pale-yellow, .w3-hover-pale-yellow:hover { + color: #000 !important; + background-color: #ffffcc !important +} + +.w3-pale-blue, .w3-hover-pale-blue:hover { + color: #000 !important; + background-color: #ddffff !important +} + +.w3-text-amber, .w3-hover-text-amber:hover { + color: #ffc107 !important +} + +.w3-text-aqua, .w3-hover-text-aqua:hover { + color: #00ffff !important +} + +.w3-text-blue, .w3-hover-text-blue:hover { + color: #2196F3 !important +} + +.w3-text-light-blue, .w3-hover-text-light-blue:hover { + color: #87CEEB !important +} + +.w3-text-brown, .w3-hover-text-brown:hover { + color: #795548 !important +} + +.w3-text-cyan, .w3-hover-text-cyan:hover { + color: #00bcd4 !important +} + +.w3-text-blue-grey, .w3-hover-text-blue-grey:hover, .w3-text-blue-gray, .w3-hover-text-blue-gray:hover { + color: #607d8b !important +} + +.w3-text-green, .w3-hover-text-green:hover { + color: #4CAF50 !important +} + +.w3-text-light-green, .w3-hover-text-light-green:hover { + color: #8bc34a !important +} + +.w3-text-indigo, .w3-hover-text-indigo:hover { + color: #3f51b5 !important +} + +.w3-text-khaki, .w3-hover-text-khaki:hover { + color: #b4aa50 !important +} + +.w3-text-lime, .w3-hover-text-lime:hover { + color: #cddc39 !important +} + +.w3-text-orange, .w3-hover-text-orange:hover { + color: #ff9800 !important +} + +.w3-text-deep-orange, .w3-hover-text-deep-orange:hover { + color: #ff5722 !important +} + +.w3-text-pink, .w3-hover-text-pink:hover { + color: #e91e63 !important +} + +.w3-text-purple, .w3-hover-text-purple:hover { + color: #9c27b0 !important +} + +.w3-text-deep-purple, .w3-hover-text-deep-purple:hover { + color: #673ab7 !important +} + +.w3-text-red, .w3-hover-text-red:hover { + color: #f44336 !important +} + +.w3-text-sand, .w3-hover-text-sand:hover { + color: #fdf5e6 !important +} + +.w3-text-teal, .w3-hover-text-teal:hover { + color: #009688 !important +} + +.w3-text-yellow, .w3-hover-text-yellow:hover { + color: #d2be0e !important +} + +.w3-text-white, .w3-hover-text-white:hover { + color: #fff !important +} + +.w3-text-black, .w3-hover-text-black:hover { + color: #000 !important +} + +.w3-text-grey, .w3-hover-text-grey:hover, .w3-text-gray, .w3-hover-text-gray:hover { + color: #757575 !important +} + +.w3-text-light-grey, .w3-hover-text-light-grey:hover, .w3-text-light-gray, .w3-hover-text-light-gray:hover { + color: #f1f1f1 !important +} + +.w3-text-dark-grey, .w3-hover-text-dark-grey:hover, .w3-text-dark-gray, .w3-hover-text-dark-gray:hover { + color: #3a3a3a !important +} + +.w3-border-amber, .w3-hover-border-amber:hover { + border-color: #ffc107 !important +} + +.w3-border-aqua, .w3-hover-border-aqua:hover { + border-color: #00ffff !important +} + +.w3-border-blue, .w3-hover-border-blue:hover { + border-color: #2196F3 !important +} + +.w3-border-light-blue, .w3-hover-border-light-blue:hover { + border-color: #87CEEB !important +} + +.w3-border-brown, .w3-hover-border-brown:hover { + border-color: #795548 !important +} + +.w3-border-cyan, .w3-hover-border-cyan:hover { + border-color: #00bcd4 !important +} + +.w3-border-blue-grey, .w3-hover-border-blue-grey:hover, .w3-border-blue-gray, .w3-hover-border-blue-gray:hover { + border-color: #607d8b !important +} + +.w3-border-green, .w3-hover-border-green:hover { + border-color: #4CAF50 !important +} + +.w3-border-light-green, .w3-hover-border-light-green:hover { + border-color: #8bc34a !important +} + +.w3-border-indigo, .w3-hover-border-indigo:hover { + border-color: #3f51b5 !important +} + +.w3-border-khaki, .w3-hover-border-khaki:hover { + border-color: #f0e68c !important +} + +.w3-border-lime, .w3-hover-border-lime:hover { + border-color: #cddc39 !important +} + +.w3-border-orange, .w3-hover-border-orange:hover { + border-color: #ff9800 !important +} + +.w3-border-deep-orange, .w3-hover-border-deep-orange:hover { + border-color: #ff5722 !important +} + +.w3-border-pink, .w3-hover-border-pink:hover { + border-color: #e91e63 !important +} + +.w3-border-purple, .w3-hover-border-purple:hover { + border-color: #9c27b0 !important +} + +.w3-border-deep-purple, .w3-hover-border-deep-purple:hover { + border-color: #673ab7 !important +} + +.w3-border-red, .w3-hover-border-red:hover { + border-color: #f44336 !important +} + +.w3-border-sand, .w3-hover-border-sand:hover { + border-color: #fdf5e6 !important +} + +.w3-border-teal, .w3-hover-border-teal:hover { + border-color: #009688 !important +} + +.w3-border-yellow, .w3-hover-border-yellow:hover { + border-color: #ffeb3b !important +} + +.w3-border-white, .w3-hover-border-white:hover { + border-color: #fff !important +} + +.w3-border-black, .w3-hover-border-black:hover { + border-color: #000 !important +} + +.w3-border-grey, .w3-hover-border-grey:hover, .w3-border-gray, .w3-hover-border-gray:hover { + border-color: #9e9e9e !important +} + +.w3-border-light-grey, .w3-hover-border-light-grey:hover, .w3-border-light-gray, .w3-hover-border-light-gray:hover { + border-color: #f1f1f1 !important +} + +.w3-border-dark-grey, .w3-hover-border-dark-grey:hover, .w3-border-dark-gray, .w3-hover-border-dark-gray:hover { + border-color: #616161 !important +} + +.w3-border-pale-red, .w3-hover-border-pale-red:hover { + border-color: #ffe7e7 !important +} + +.w3-border-pale-green, .w3-hover-border-pale-green:hover { + border-color: #e7ffe7 !important +} + +.w3-border-pale-yellow, .w3-hover-border-pale-yellow:hover { + border-color: #ffffcc !important +} + +.w3-border-pale-blue, .w3-hover-border-pale-blue:hover { + border-color: #e7ffff !important +} \ No newline at end of file diff --git a/haugebot_web/static/images/logo.png b/haugebot_web/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..00a09cfe416e2047ca4cb5616e1930870c1e5ff5 GIT binary patch literal 17377 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 literal 0 HcmV?d00001 diff --git a/haugebot_web/templates/color_select_option.html b/haugebot_web/templates/color_select_option.html new file mode 100644 index 0000000..8ad45ca --- /dev/null +++ b/haugebot_web/templates/color_select_option.html @@ -0,0 +1,2 @@ +<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %} + style="{{ widget.value|stringformat:'s' }}">{{ widget.label }}</option> diff --git a/haugebot_web/templates/form.html b/haugebot_web/templates/form.html new file mode 100644 index 0000000..5b2a1bd --- /dev/null +++ b/haugebot_web/templates/form.html @@ -0,0 +1,52 @@ +{% extends 'layout.html' %} + +{% block content %} + <form method="post"> + {% csrf_token %} + {% if form %} + <div class="w3-card w3-white w3-display-container"> + <div class="w3-container w3-form-container"> + {{ form.non_field_errors }} + {% for field in form %} + <p> + {{ field.errors }} + {{ field.label_tag }} + {{ field }} + </p> + {% endfor %} + </div> + </div> + {% endif %} + {{ formset.management_form }} + {% for form in formset %} + <div class="w3-card w3-white w3-display-container"> + <div class="w3-container w3-form-container"> + {{ form.non_field_errors }} + {% for field in form %} + <p> + {{ field.errors }} + {{ field }} + {% if field.widget_type == "checkbox" %} + {{ field.label_tag }} + {% endif %} + </p> + {% endfor %} + </div> + {% if form.fields.id.initial %} + <div class="w3-display-topright"> + <p><a href="{% url remove_url form.fields.id.initial %}" class="w3-button w3-haugepink"><i + class="fas fa-trash-alt"></i></a></p> + </div> + {% endif %} + </div> + {% endfor %} + <input type="submit" value="Add/Save" class="w3-button w3-haugepink w3-block"> + </form> + + <script> + color_field_options = document.querySelectorAll("#id_color_field option"); + color_field_options.forEach(value => { + value.style = "background: " + value.getAttribute("value") + ";" + }) + </script> +{% endblock %} \ No newline at end of file diff --git a/haugebot_web/templates/home.html b/haugebot_web/templates/home.html new file mode 100644 index 0000000..e1011a6 --- /dev/null +++ b/haugebot_web/templates/home.html @@ -0,0 +1,5 @@ +{% extends 'layout.html' %} + +{% block content %} + {{ status }} +{% endblock %} \ No newline at end of file diff --git a/haugebot_web/templates/layout.html b/haugebot_web/templates/layout.html new file mode 100644 index 0000000..d5162df --- /dev/null +++ b/haugebot_web/templates/layout.html @@ -0,0 +1,36 @@ +{% load static %} + +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>{{ title }}</title> + <link rel="shortcut icon" type="image/png" href="{% static 'images/logo.png' %}"> + <script src="https://kit.fontawesome.com/cdec119da7.js" crossorigin="anonymous"></script> + <link rel="stylesheet" href="{% static 'css/w3.css' %}" type="text/css"> + <link rel="stylesheet" href="{% static 'css/styles.css' %}" type="text/css"> +</head> +<body class="w3-light-gray"> +<nav> + <div class="w3-sidebar w3-bar-block w3-haugedark "> + <a href="{% url 'home' %}" class="logo"><img src="{% static 'images/logo.png' %}" id="logo"/></a> + {% if user.is_authenticated %} + <a href="{% url 'wusstest_du_schon' %}" + class="w3-bar-item w3-button {% if title == "Wusstest du Schon?" %}w3-light-gray{% endif %}">Wusstest du + Schon?</a> + <a href="{% url 'logout' %}" class="w3-bar-item w3-button">Logout</a> + {% else %} + <a href="{% url 'login' %}" class="w3-bar-item w3-button">Login</a> + {% endif %} + </div> +</nav> +<div id="content"> + <div class="w3-container"> + <h1>{{ title }}</h1> + </div> + <div class="w3-container"> + {% block content %}{% endblock %} + </div> +</div> +</body> +</html> \ No newline at end of file diff --git a/haugebot_web/tests.py b/haugebot_web/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/haugebot_web/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/haugebot_web/twitch_api.py b/haugebot_web/twitch_api.py new file mode 100644 index 0000000..c544f54 --- /dev/null +++ b/haugebot_web/twitch_api.py @@ -0,0 +1,109 @@ +import os +from datetime import datetime + +import requests +from django.utils.http import urlencode + +token_url = "https://id.twitch.tv/oauth2/token?client_id={0}&client_secret={1}&grant_type=client_credentials" +clips_url = "https://api.twitch.tv/helix/clips?broadcaster_id={0}&first=100" +broadcaster_id = os.getenv("BROADCASTER_ID") +client_id = os.getenv("CLIENT_ID") +client_secret = os.getenv("CLIENT_SECRET") +access_token = None + + +def get_access_token(): + global access_token + + response = requests.post(token_url.format(client_id, client_secret)) + credentials = response.json() + access_token = credentials["access_token"] + + +def call(url): + if access_token: + response = requests.get(url, headers={ + 'Authorization': 'Bearer {}'.format(access_token), + 'Client-Id': client_id + }) + if response.status_code == 200: + return response.json() + else: + get_access_token() + response = requests.get(url, headers={ + 'Authorization': 'Bearer {}'.format(access_token), + 'Client-Id': client_id + }) + if response.status_code == 200: + return response.json() + else: + get_access_token() + response = requests.get(url, headers={ + 'Authorization': 'Bearer {}'.format(access_token), + 'Client-Id': client_id + }) + if response.status_code == 200: + return response.json() + + +def get_clips(cursor=None, all_clips=False, today=False): + clips = [] + url = clips_url.format(broadcaster_id) + if cursor: + url += "&after=" + cursor + if today: + url += "&started_at=" + get_date() + + clips_json = call(url) + + if data := clips_json.get("data"): + for clip in data: + if thumbnail_url := clip.get("thumbnail_url"): + clip_url = thumbnail_url.split("-preview-")[0] + ".mp4" + clips.append(clip_url) + + if all_clips: + if pagination := clips_json.get("pagination"): + if new_cursor := pagination.get("cursor"): + clips.extend(get_clips(cursor=new_cursor, all_clips=all_clips, today=today)) + return clips + + +def get_date(): + now = datetime.now() + dt = datetime(day=now.day, month=now.month, year=now.year) + return dt.isoformat() + "Z" + + +def is_mod(user, broadcaster): + if user.id == broadcaster.id: + return True + + response = requests.get( + f"https://api.twitch.tv/helix/moderation/moderators?broadcaster_id={broadcaster.id}&user_id={user.id}", + headers={ + 'Authorization': f'Bearer {broadcaster.access_token}', + 'Client-Id': client_id + }) + + if response.status_code == 401 and response.json().get("message") == "Invalid OAuth token": + if not refresh_access_token(broadcaster): + return False + return is_mod(user, broadcaster) + + return response.json().get("data") is not None + + +def refresh_access_token(broadcaster): + url = "https://id.twitch.tv/oauth2/token?" + urlencode( + {"grant_type": "refresh_token", "refresh_token": broadcaster.refresh_token, "client_id": client_id, + "client_secret": client_secret}) + response = requests.post(url) + + if response.status_code == 400 and response.json().get("message") == "Invalid refresh token": + return None + + json = response.json() + access_token = json["access_token"] + refresh_token = json["refresh_token"] + broadcaster.update_tokens(access_token, refresh_token) diff --git a/haugebot_web/views.py b/haugebot_web/views.py new file mode 100644 index 0000000..688e965 --- /dev/null +++ b/haugebot_web/views.py @@ -0,0 +1,88 @@ +import os + +import requests +from django.contrib.auth import authenticate, login as django_login, logout as django_logout +from django.contrib.auth.decorators import login_required +from django.forms import modelformset_factory +from django.shortcuts import render, redirect + +from .forms import BaseForm, WusstestDuSchonSettingsForm +from .models import WusstestDuSchon + + +# Create your views here. +def home(request): + return render(request, "home.html", {'title': 'HaugeBot'}) + + +@login_required(login_url="/login") +def wusstest_du_schon(request): + WusstestDuSchonFormSet = modelformset_factory(WusstestDuSchon, form=BaseForm, + fields=('advertised_command', 'text', 'use_prefix', 'active'), + field_classes=['']) + if request.method == "POST": + settings_form = WusstestDuSchonSettingsForm(request.POST) + formset = WusstestDuSchonFormSet(request.POST, request.FILES) + if formset.is_valid(): + formset.save() + if settings_form.is_valid(): + settings_form.save() + + formset = WusstestDuSchonFormSet() + settings_form = WusstestDuSchonSettingsForm() + + return render(request, "form.html", + {'title': 'Wusstest du Schon?', 'formset': formset, 'remove_url': 'wusstest_du_schon_remove', + 'form': settings_form}) + + +@login_required(login_url="/login") +def wusstest_du_schon_remove(request, id): + WusstestDuSchon.objects.filter(pk=id).delete() + + return redirect("/wusstest_du_schon") + + +def login(request): + client_id = os.getenv("CLIENT_ID") + redirect_uri = os.getenv("REDIRECT_URI") + url = f"https://id.twitch.tv/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope=moderation:read" + return redirect(url) + + +def logout(request): + django_logout(request) + return redirect("/") + + +def login_redirect(request): + code = request.GET.get('code') + user = exchange_code(code) + if user: + twitch_user = authenticate(request, user=user) + twitch_user = list(twitch_user).pop() + django_login(request, twitch_user) + + return redirect("/") + + +def exchange_code(code): + client_id = os.getenv("CLIENT_ID") + client_secret = os.getenv("CLIENT_SECRET") + redirect_uri = os.getenv("REDIRECT_URI") + url = f"https://id.twitch.tv/oauth2/token?client_id={client_id}&client_secret={client_secret}&code={code}&grant_type=authorization_code&redirect_uri={redirect_uri}" + response = requests.post(url) + if response.status_code == 200: + credentials = response.json() + + response = requests.get("https://api.twitch.tv/helix/users", headers={ + 'Authorization': f'Bearer {credentials["access_token"]}', + 'Client-Id': client_id + }) + + user = response.json()["data"][0] + + return {'id': user['id'], 'login': user['login'], 'access_token': credentials['access_token'], + 'refresh_token': credentials['refresh_token']} + + return None diff --git a/info.json b/info.json deleted file mode 100644 index 7cc9087..0000000 --- a/info.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - "mit !subs kannst du dir anzeigen lassen, wie viele haugeSun -e Menschen diesen Kanal derzeit mit einem Sub unterstützen.", - "mit !time kannst du dir die aktuelle Uhrzeit bei Hauke in Tokyo anzeigen lassen.", - "mit !pipi kannst du Hauke einen Hinweis hinterlassen, dass du demnächst mal eine Pause bräuchtest, um zum Beispiel Pipi zu machen. Außerdem kannst du mit !warpipi mitteilen, dass sich dein Wunsch nach einer Pause inzwischen erledigt hat.", - "mit !wishlist erhälst du den Link zu Haukes Amazon Wishlist.", - "mit !zeit kannst du dir die aktuelle Uhrzeit bei Hauke in Tokyo anzeigen lassen.", - "mit !amazon erhälst du Haukes Amazon Affiliate Link.", - "mit !art erhälst du die Information, wer für das wunderbare Design dieses tollen Twitch-Kanals verantwortlich ist.", - "mit !artists erhälst du die Links zu allen Musik Artists, deren Musik hier während des Streams immer im Hintergrund läuft.", - "mit !campfire bekommst du weitere Informationen zur interaktiven Hörspielapp Campfire.", - "mit !discord erfährst du den Link zum Discord Server dieses Kanals.", - "mit !donate erhälst du weitere Informationen, wie du Hauke durch eine Spende unterstützen kannst.", - "mit !game bekommst du weitere Informationen zum Morriton Manor Spiel.", - "wenn du Hauke auf Instagram folgen möchtest, dann schreib einfach !instagram in den Chat und du bekommst den Link zu Haukes Profil.", - "wenn dir die Playlist, die gerade im Hintergrund läuft gefällt, dann schreib doch mal !playlist in den Chat und du erhälst den Link zur Spotify Playlist.", - "falls du nicht genug von Hauke und Japan bekommen kannst, einfach !podcast in den Chat und du bekommst den Link zu seinem FYEO Podcast.", - "als Besitzer von Amazon Prime kannst du jeden Monat einen Twitch Kanal deiner Wahl kostenlos abonnieren. Falls du wissen möchtest, wie das geht, !prime in den Chat und du bekommst alle notwendigen Informationen.", - "wenn du studierst und noch kein Amazon Prime hast, dann kannst du dies zu bevorzugten Konditionen abschließen. Falls dich das interessiert, gib einfach mal !primestudent in den Chat und du erhälst einen Link mit allen notwendigen Informationen.", - "als alte(r) Reisliebhaber(in) kannst du von Haukes Partnerschaft mit Reishunger profitieren. Alle Informationen hierzu: !reishunger", - "mit !song kannst du dir Informationen zum aktuell laufenden Song anzeigen lassen.", - "du kannst sogar subben, wenn du den Stream am Handy schaust. Gib mal !sub in den Chat ein, wenn dich interessiert, wie das geht.", - "falls du Hauke auch auf Twitter folgen möchtest, !twitter in den Chat, und du bekommst sofort einen Link dafür.", - "mit !wort kannst du dir das Wort des Tages anzeigen lassen.", - "Hauke hat nicht nur einen Twitch Kanal, auf dem er regelmäßig streamt. Regelmäßig erscheinen auf YouTube auch Videos mit Ausschnitten des Streams, oder extra dafür produzierten Videos. Mit !youtube erhälst du den Link zum YouTube Kanal." -] \ No newline at end of file diff --git a/info_cog.py b/info_cog.py deleted file mode 100644 index 51e6d89..0000000 --- a/info_cog.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -import json -import logging -import os -import random -from datetime import datetime - -from twitchio.ext import commands - - -@commands.core.cog(name="InfoCog") -class InfoCog: - def __init__(self, bot): - self.bot = bot - self.info_file = os.getenv("INFO_JSON") - self.INFO_LOOP = float(os.getenv("INFO_LOOP")) - self.INFO_COLOR = os.getenv("INFO_COLOR") - self.info = [] - self.load_info() - - def load_info(self): - """ Loads all info that should be sent to the chat regularly from INFO_JSON """ - - info_file = open(self.info_file, mode='r') - self.info = json.load(info_file) - - async def info_loop(self): - """ Send !pipimeter into the chat every x Minutes. Also check, whether the stream was offline for x Minutes. - If this is true, reset the pipi counter, as you can assume, that the stream recently started.""" - - logging.log(logging.INFO, f"Info loop started. {datetime.now()} {self}") - - while True: - logging.log(logging.INFO, f"Inside Info loop. Sleep for {self.INFO_LOOP} minutes. {datetime.now()} {self}") - await asyncio.sleep(self.INFO_LOOP * 60) - logging.log(logging.INFO, f"Inside Info loop finished sleeping now. {datetime.now()} {self}") - - if await self.bot.stream(): - logging.log(logging.INFO, - f"Inside Info loop. Stream is online, so send a message now!!! {datetime.now()} {self}") - channel = self.bot.channel() - message = f"Psssst... wusstest du eigentlich schon, {random.choice(self.info)}" - await self.bot.send_me(channel, message, self.INFO_COLOR) - - logging.log(logging.INFO, - f"Inside Info loop. Ooooookay, Loop ended, let's continue with the next round!!! {datetime.now()} {self}") diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..fb5aa8f --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haugebot.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt index 57f9216..c794e3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,19 @@ -aiohttp==3.6.2 +aiohttp==3.7.3 +asgiref==3.3.1 async-timeout==3.0.1 -attrs==19.3.0 +attrs==20.3.0 +certifi==2020.12.5 chardet==3.0.4 +Django==3.1.5 idna==2.10 -multidict==4.7.6 -python-dotenv==0.14.0 +multidict==5.1.0 +python-dotenv==0.15.0 +pytz==2020.5 +redis==3.5.3 +requests==2.25.1 +sqlparse==0.4.1 twitchio==1.1.0 +typing-extensions==3.7.4.3 +urllib3==1.26.2 websockets==8.1 -yarl==1.4.2 -redis==3.5.3 +yarl==1.6.3 -- GitLab