diff --git a/.gitignore b/.gitignore index 69da0bf513c4e8ebdb04cd38baa98e3ca5d75a58..76aa668227b7fddd47fc0d70d92af05afc97820c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ db.sqlite3 .mypy_cache *.pyc .pytest_cache +.cache/ static/ steering_data_model.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5baf8e119b0c1c6791e37442746d705796ee89..1f345d81c5f6312582d8972518e7a85b7276ee99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ... +- `Image` concrete model to handle all instances. +- API endpoint `/api/v1/images/` to add, update and delete images. ### Changed diff --git a/fixtures/program/category.json b/fixtures/program/category.json index d41ea21d6cef91f13a5b17d8fdee26f605b0a825..7dd5d90b3fc1edbd2fbb16e6acae841c0c129d39 100644 --- a/fixtures/program/category.json +++ b/fixtures/program/category.json @@ -4,7 +4,6 @@ "pk": 1, "fields": { "name": "Interkulturell", - "abbrev": "I", "slug": "interkulturell", "is_active": true, "description": "" @@ -15,7 +14,6 @@ "pk": 2, "fields": { "name": "Lokalbezug", - "abbrev": "L", "slug": "lokalbezug", "is_active": true, "description": "" @@ -26,7 +24,6 @@ "pk": 3, "fields": { "name": "Minderheiten", - "abbrev": "Mi", "slug": "minderheiten", "is_active": true, "description": "" @@ -37,7 +34,6 @@ "pk": 4, "fields": { "name": "Wiederholung", - "abbrev": "W", "slug": "wiederholung", "is_active": true, "description": "" @@ -48,7 +44,6 @@ "pk": 5, "fields": { "name": "Mehr-/Fremdsprachig", - "abbrev": "M", "slug": "mehr-fremdsprachig", "is_active": true, "description": "" @@ -59,7 +54,6 @@ "pk": 6, "fields": { "name": "Frauenschwerpunkt", - "abbrev": "F", "slug": "frauenschwerpunkt", "is_active": true, "description": "" @@ -70,7 +64,6 @@ "pk": 7, "fields": { "name": "Österreichische Musik", - "abbrev": "Ö", "slug": "osterreichische-musik", "is_active": true, "description": "" @@ -81,7 +74,6 @@ "pk": 8, "fields": { "name": "Sendungsübernahme", - "abbrev": "U", "slug": "sendungsubernahme", "is_active": true, "description": "" diff --git a/fixtures/program/fundingcategory.json b/fixtures/program/fundingcategory.json index 8e4b28f5d8f86fef8626a008778932a6c7e45cab..a8c71fb09406f6a5c402202b110c5e5abbd89568 100644 --- a/fixtures/program/fundingcategory.json +++ b/fixtures/program/fundingcategory.json @@ -4,7 +4,6 @@ "pk": 1, "fields": { "name": "Standard", - "abbrev": "S", "slug": "standard", "is_active": true } diff --git a/fixtures/program/host.json b/fixtures/program/host.json index d4c3989ab68ddfcdfdc8b83a79a0458810e49e58..bd70ac3765af7bfecaeb223aeebead8cf3b65134 100644 --- a/fixtures/program/host.json +++ b/fixtures/program/host.json @@ -6,12 +6,10 @@ "name": "Musikredaktion", "is_active": true, "email": "", - "website": "", "biography": null, - "ppoi": "0.5x0.5", - "height": null, - "width": null, - "image": "" + "image": null, + "created_at": "2000-06-01 00:00Z", + "created_by": "loaddata" } } ] diff --git a/fixtures/program/licensetype.json b/fixtures/program/licensetype.json new file mode 100644 index 0000000000000000000000000000000000000000..8661b1d2bf3a7ca57c44e079251e56e325f1fa83 --- /dev/null +++ b/fixtures/program/licensetype.json @@ -0,0 +1,66 @@ +[ + { + "model": "program.licensetype", + "pk": 1, + "fields": { + "name": "pd", + "type": "Public Domain" + } + }, + { + "model": "program.licensetype", + "pk": 2, + "fields": { + "name": "cc-by", + "type": "Creative Commons Attribution" + } + }, + { + "model": "program.licensetype", + "pk": 3, + "fields": { + "name": "cc-by-sa", + "type": "Creative Commons Attribution-ShareAlike" + } + }, + { + "model": "program.licensetype", + "pk": 4, + "fields": { + "name": "cc-by-nc", + "type": "Creative Commons Attribution-NonCommercial" + } + }, + { + "model": "program.licensetype", + "pk": 5, + "fields": { + "name": "cc-by-nc-sa", + "type": "Creative Commons Attribution-NonCommercial-ShareAlike" + } + }, + { + "model": "program.licensetype", + "pk": 6, + "fields": { + "name": "cc-by-nd", + "type": "Creative Commons Attribution-NoDerivatives" + } + }, + { + "model": "program.licensetype", + "pk": 7, + "fields": { + "name": "cc-by-nc-nd", + "type": "Creative Commons Attribution-NonCommercial-NoDerivatives" + } + }, + { + "model": "program.licensetype", + "pk": 8, + "fields": { + "name": "gfdl", + "type": "GNU Free Documentation License" + } + } +] \ No newline at end of file diff --git a/fixtures/program/linktype.json b/fixtures/program/linktype.json new file mode 100644 index 0000000000000000000000000000000000000000..c5b6968193551fee00e8c45bb80d5d4ad993560d --- /dev/null +++ b/fixtures/program/linktype.json @@ -0,0 +1,42 @@ +[ + { + "model": "program.linktype", + "pk": 1, + "fields": { + "name": "homepage", + "type": "Homepage" + } + }, + { + "model": "program.linktype", + "pk": 2, + "fields": { + "name": "website", + "type": "Website" + } + }, + { + "model": "program.linktype", + "pk": 3, + "fields": { + "name": "cba-podcast", + "type": "Cultural Broadcasting Archive Podcast" + } + }, + { + "model": "program.linktype", + "pk": 4, + "fields": { + "name": "cba-post", + "type": "Cultural Broadcasting Archive Post" + } + }, + { + "model": "program.linktype", + "pk": 5, + "fields": { + "name": "soundcloud", + "type": "SoundCloud" + } + } +] \ No newline at end of file diff --git a/fixtures/program/musicfocus.json b/fixtures/program/musicfocus.json index 26a5922f56d08304406fd2cf12796f6fb5c57d63..f85c89981088f1f40a429437c514331000b211f4 100644 --- a/fixtures/program/musicfocus.json +++ b/fixtures/program/musicfocus.json @@ -4,7 +4,6 @@ "pk": 1, "fields": { "name": "Jazz", - "abbrev": "J", "slug": "jazz", "is_active": true } @@ -14,7 +13,6 @@ "pk": 2, "fields": { "name": "Volksmusik/Folk", - "abbrev": "V", "slug": "volksmusik-folk", "is_active": true } @@ -24,7 +22,6 @@ "pk": 3, "fields": { "name": "Experimentelle Musik", - "abbrev": "Ex", "slug": "expermentelle-musik", "is_active": true } @@ -34,7 +31,6 @@ "pk": 4, "fields": { "name": "Rock/Indie", - "abbrev": "R", "slug": "rock-indie", "is_active": true } @@ -44,7 +40,6 @@ "pk": 5, "fields": { "name": "Metal/Hardrock", - "abbrev": "M", "slug": "metal-hardrock", "is_active": true } @@ -54,7 +49,6 @@ "pk": 6, "fields": { "name": "Electronic", - "abbrev": "E", "slug": "electronic", "is_active": true } @@ -64,7 +58,6 @@ "pk": 7, "fields": { "name": "Klassik", - "abbrev": "K", "slug": "klassik", "is_active": true } @@ -74,7 +67,6 @@ "pk": 8, "fields": { "name": "Oldies", - "abbrev": "O", "slug": "oldies", "is_active": true } @@ -84,7 +76,6 @@ "pk": 9, "fields": { "name": "Reggae/Ska", - "abbrev": "Re", "slug": "reggae-ska", "is_active": true } @@ -94,7 +85,6 @@ "pk": 10, "fields": { "name": "Hiphop", - "abbrev": "H", "slug": "hiphop", "is_active": true } diff --git a/fixtures/program/rrule.json b/fixtures/program/rrule.json index 365374e972b8e9922416aadd18b49b6ac55ffb1a..dc348f6e38dbb51bf71c2128babc2beddd184112 100644 --- a/fixtures/program/rrule.json +++ b/fixtures/program/rrule.json @@ -130,5 +130,245 @@ "by_weekdays": null, "count": null } + }, + { + "model": "program.rrule", + "pk": 110, + "fields": { + "name": "monatlich am letzten", + "freq": 1, + "interval": 1, + "by_set_pos": -1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 120, + "fields": { + "name": "zwei-monatlich am letzten", + "freq": 1, + "interval": 2, + "by_set_pos": -1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 121, + "fields": { + "name": "zwei-monatlich am ersten", + "freq": 1, + "interval": 2, + "by_set_pos": 1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 122, + "fields": { + "name": "zwei-monatlich am zweiten", + "freq": 1, + "interval": 2, + "by_set_pos": 2, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 123, + "fields": { + "name": "zwei-monatlich am dritten", + "freq": 1, + "interval": 2, + "by_set_pos": 3, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 124, + "fields": { + "name": "zwei-monatlich am vierten", + "freq": 1, + "interval": 2, + "by_set_pos": 4, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 125, + "fields": { + "name": "zwei-monatlich am fünften", + "freq": 1, + "interval": 2, + "by_set_pos": 5, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 130, + "fields": { + "name": "drei-monatlich am letzten", + "freq": 1, + "interval": 3, + "by_set_pos": -1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 131, + "fields": { + "name": "drei-monatlich am ersten", + "freq": 1, + "interval": 3, + "by_set_pos": 1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 132, + "fields": { + "name": "drei-monatlich am zweiten", + "freq": 1, + "interval": 3, + "by_set_pos": 2, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 133, + "fields": { + "name": "drei-monatlich am dritten", + "freq": 1, + "interval": 3, + "by_set_pos": 3, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 134, + "fields": { + "name": "drei-monatlich am vierten", + "freq": 1, + "interval": 3, + "by_set_pos": 4, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 135, + "fields": { + "name": "drei-monatlich am fünften", + "freq": 1, + "interval": 3, + "by_set_pos": 5, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 140, + "fields": { + "name": "vier-monatlich am letzten", + "freq": 1, + "interval": 4, + "by_set_pos": -1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 141, + "fields": { + "name": "vier-monatlich am ersten", + "freq": 1, + "interval": 4, + "by_set_pos": 1, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 142, + "fields": { + "name": "vier-monatlich am zweiten", + "freq": 1, + "interval": 4, + "by_set_pos": 2, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 143, + "fields": { + "name": "vier-monatlich am dritten", + "freq": 1, + "interval": 4, + "by_set_pos": 3, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 144, + "fields": { + "name": "vier-monatlich am vierten", + "freq": 1, + "interval": 4, + "by_set_pos": 4, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 145, + "fields": { + "name": "vier-monatlich am fünften", + "freq": 1, + "interval": 4, + "by_set_pos": 5, + "by_weekdays": null, + "count": null + } + }, + { + "model": "program.rrule", + "pk": 2111, + "fields": { + "name": "am Wochenende", + "freq": 2, + "interval": 1, + "by_set_pos": null, + "by_weekdays": "5,6", + "count": null + } } ] diff --git a/fixtures/program/show.json b/fixtures/program/show.json index 778075b642628d57792d4c88036a2a066fac7eea..597026872db92c22c309727ae0f7671cf6eccacc 100644 --- a/fixtures/program/show.json +++ b/fixtures/program/show.json @@ -8,15 +8,11 @@ "funding_category": 1, "name": "Musikprogramm", "slug": "musikprogramm", - "ppoi": "0.5x0.5", - "height": null, - "width": null, - "image": "", - "logo": "", + "image": null, + "logo": null, "short_description": "Unmoderiertes Musikprogramm", "description": "Unmoderiertes Musikprogramm", "email": "musikredaktion@helsinki.at", - "website": null, "cba_series_id": null, "default_playlist_id": null, "is_active": true, @@ -28,7 +24,9 @@ "language": [], "category": [], "topic": [], - "music_focus": [] + "music_focus": [], + "created_at": "2000-06-01 00:00Z", + "created_by": "loaddata" } } ] diff --git a/fixtures/program/topic.json b/fixtures/program/topic.json index 0d69ffbc56abeb1ca598f1e7ba91260a265c3ce1..0aec75a3cba0a5d1815e04148f6d75e9c8bf0589 100644 --- a/fixtures/program/topic.json +++ b/fixtures/program/topic.json @@ -4,7 +4,6 @@ "pk": 1, "fields": { "name": "Politik/Gesellschaft", - "abbrev": "P", "slug": "politik-gesellschaft", "is_active": true } @@ -14,7 +13,6 @@ "pk": 2, "fields": { "name": "Natur/Klima/Tiere", - "abbrev": "N", "slug": "natur-klima-tiere", "is_active": true } @@ -24,7 +22,6 @@ "pk": 3, "fields": { "name": "Kultur/Kunst", - "abbrev": "K", "slug": "kultur-kunst", "is_active": true } @@ -34,7 +31,6 @@ "pk": 4, "fields": { "name": "Soziales", - "abbrev": "S", "slug": "soziales", "is_active": true } @@ -44,7 +40,6 @@ "pk": 5, "fields": { "name": "Wissenschaft/Philosophie", - "abbrev": "W", "slug": "wissenschaft-philosophie", "is_active": true } diff --git a/poetry.lock b/poetry.lock index 5432cf2b013dbcc6a69dc4f234854858596ae538..09f26e2d25aa9611e4006fe7f2b8f7e92af46b78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -402,18 +402,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.10.7" +version = "3.11.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "filelock-3.10.7-py3-none-any.whl", hash = "sha256:bde48477b15fde2c7e5a0713cbe72721cb5a5ad32ee0b8f419907960b9d75536"}, - {file = "filelock-3.10.7.tar.gz", hash = "sha256:892be14aa8efc01673b5ed6589dbccb95f9a8596f0507e232626155495c18105"}, + {file = "filelock-3.11.0-py3-none-any.whl", hash = "sha256:f08a52314748335c6460fc8fe40cd5638b85001225db78c2aa01c8c0db83b318"}, + {file = "filelock-3.11.0.tar.gz", hash = "sha256:3618c0da67adcc0506b015fd11ef7faf1b493f0b40d87728e19986b536890c37"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.2)", "diff-cover (>=7.5)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] diff --git a/profile/__init__.py b/profile/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/profile/admin.py b/profile/admin.py deleted file mode 100644 index 0a93c66103e1e3aec33629c013b9597d77cb5c86..0000000000000000000000000000000000000000 --- a/profile/admin.py +++ /dev/null @@ -1,70 +0,0 @@ -# -# steering, Programme/schedule management for AURA -# -# Copyright (C) 2017-2018, Ingo Leindecker -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -# details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -# - -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin -from django.contrib.auth.models import User - -from .models import Profile - - -class ProfileInline(admin.StackedInline): - model = Profile - can_delete = False - verbose_name_plural = "Profile" - fk_name = "user" - - -class ProfileUserAdmin(UserAdmin): - inlines = (ProfileInline,) - - def get_queryset(self, request): - """Let common users only edit their own profile""" - if not request.user.is_superuser: - return ( - super(UserAdmin, self).get_queryset(request).filter(pk=request.user.id) - ) - - return super(UserAdmin, self).get_queryset(request) - - def get_readonly_fields(self, request, obj=None): - """Limit field access for common users""" - if not request.user.is_superuser: - return ( - "username", - "is_staff", - "is_superuser", - "is_active", - "date_joined", - "last_login", - "groups", - "user_permissions", - ) - return list() - - def get_inline_instances(self, request, obj=None): - """Append profile fields to UserAdmin""" - if not obj: - return list() - - return super(ProfileUserAdmin, self).get_inline_instances(request, obj) - - -admin.site.unregister(User) -admin.site.register(User, ProfileUserAdmin) diff --git a/profile/migrations/0001_initial.py b/profile/migrations/0001_initial.py deleted file mode 100644 index ad7c45b8559e5f0f484406bfd52958667e21d2cc..0000000000000000000000000000000000000000 --- a/profile/migrations/0001_initial.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-11-09 18:42 -from __future__ import unicode_literals - -import versatileimagefield.fields - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Profile", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "biography", - models.TextField(blank=True, null=True, verbose_name="Biography"), - ), - ("website", models.URLField(blank=True, verbose_name="Website")), - ( - "googleplus_url", - models.URLField(blank=True, verbose_name="Google+ URL"), - ), - ( - "facebook_url", - models.URLField(blank=True, verbose_name="Facebook URL"), - ), - ( - "twitter_url", - models.URLField(blank=True, verbose_name="Twitter URL"), - ), - ( - "linkedin_url", - models.URLField(blank=True, verbose_name="LinkedIn URL"), - ), - ( - "youtube_url", - models.URLField(blank=True, verbose_name="Youtube URL"), - ), - ("dorftv_url", models.URLField(blank=True, verbose_name="DorfTV URL")), - ("cba_url", models.URLField(blank=True, verbose_name="CBA URL")), - ( - "cba_username", - models.CharField( - blank=True, max_length=60, verbose_name="CBA Username" - ), - ), - ( - "cba_user_token", - models.CharField( - blank=True, max_length=255, verbose_name="CBA Token" - ), - ), - ( - "ppoi", - versatileimagefield.fields.PPOIField( - default="0.5x0.5", - editable=False, - max_length=20, - verbose_name="Image PPOI", - ), - ), - ( - "height", - models.PositiveIntegerField( - blank=True, - editable=False, - null=True, - verbose_name="Image Height", - ), - ), - ( - "width", - models.PositiveIntegerField( - blank=True, - editable=False, - null=True, - verbose_name="Image Width", - ), - ), - ( - "image", - versatileimagefield.fields.VersatileImageField( - blank=True, - height_field="height", - null=True, - upload_to="user_images", - verbose_name="Profile picture", - width_field="width", - ), - ), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "db_table": "profile", - }, - ), - ] diff --git a/profile/migrations/0001_squashed.py b/profile/migrations/0001_squashed.py deleted file mode 100644 index 1313caa100beedfb221a769bf81943e0f62e0541..0000000000000000000000000000000000000000 --- a/profile/migrations/0001_squashed.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 2.2.12 on 2020-11-21 01:34 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [ - ("profile", "0001_initial"), - ("profile", "0002_auto_20171129_1828"), - ("profile", "0003_auto_20171213_1737"), - ] - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Profile", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "cba_username", - models.CharField( - blank=True, - help_text="Your username in CBA. This is necessary for uploading files to" - " your account.", - max_length=60, - verbose_name="CBA Username", - ), - ), - ( - "cba_user_token", - models.CharField( - blank=True, - help_text="The CBA upload token for your account. This is NOT your" - " password which you use to log into CBA!", - max_length=255, - verbose_name="CBA Token", - ), - ), - ( - "user", - models.OneToOneField( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - related_name="profile", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "db_table": "profile", - }, - ), - ] diff --git a/profile/migrations/0002_auto_20171129_1828.py b/profile/migrations/0002_auto_20171129_1828.py deleted file mode 100644 index 389554246f612dd8ce7674c8f2e7722fac746b21..0000000000000000000000000000000000000000 --- a/profile/migrations/0002_auto_20171129_1828.py +++ /dev/null @@ -1,146 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-11-29 18:28 -from __future__ import unicode_literals - -import versatileimagefield.fields - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("profile", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="profile", - name="biography", - field=models.TextField( - blank=True, - help_text="Describe yourself and your fields of interest in a few sentences.", - null=True, - verbose_name="Biography", - ), - ), - migrations.AlterField( - model_name="profile", - name="cba_url", - field=models.URLField( - blank=True, help_text="URL to your CBA profile.", verbose_name="CBA URL" - ), - ), - migrations.AlterField( - model_name="profile", - name="cba_user_token", - field=models.CharField( - blank=True, - help_text="The CBA upload token for your account. This is NOT your password which" - " you use to log into CBA!", - max_length=255, - verbose_name="CBA Token", - ), - ), - migrations.AlterField( - model_name="profile", - name="cba_username", - field=models.CharField( - blank=True, - help_text="Your username in CBA. This is necessary for uploading files to your" - " account.", - max_length=60, - verbose_name="CBA Username", - ), - ), - migrations.AlterField( - model_name="profile", - name="dorftv_url", - field=models.URLField( - blank=True, - help_text="URL to your dorfTV channel.", - verbose_name="DorfTV URL", - ), - ), - migrations.AlterField( - model_name="profile", - name="facebook_url", - field=models.URLField( - blank=True, - help_text="URL to your Facebook profile.", - verbose_name="Facebook URL", - ), - ), - migrations.AlterField( - model_name="profile", - name="googleplus_url", - field=models.URLField( - blank=True, - help_text="URL to your Google+ profile.", - verbose_name="Google+ URL", - ), - ), - migrations.AlterField( - model_name="profile", - name="image", - field=versatileimagefield.fields.VersatileImageField( - blank=True, - height_field="height", - help_text="Upload a picture of yourself. Images are automatically cropped around" - " the 'Primary Point of Interest'. Click in the image to change it and" - " press Save.", - null=True, - upload_to="user_images", - verbose_name="Profile picture", - width_field="width", - ), - ), - migrations.AlterField( - model_name="profile", - name="linkedin_url", - field=models.URLField( - blank=True, - help_text="URL to your LinkedIn profile.", - verbose_name="LinkedIn URL", - ), - ), - migrations.AlterField( - model_name="profile", - name="twitter_url", - field=models.URLField( - blank=True, - help_text="URL to your Twitter profile.", - verbose_name="Twitter URL", - ), - ), - migrations.AlterField( - model_name="profile", - name="user", - field=models.OneToOneField( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - related_name="profile", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="profile", - name="website", - field=models.URLField( - blank=True, - help_text="URL to your personal website.", - verbose_name="Website", - ), - ), - migrations.AlterField( - model_name="profile", - name="youtube_url", - field=models.URLField( - blank=True, - help_text="URL to your Youtube channel.", - verbose_name="Youtube URL", - ), - ), - ] diff --git a/profile/migrations/0002_auto_20220117_1721.py b/profile/migrations/0002_auto_20220117_1721.py deleted file mode 100644 index e209efdea852e921a271540fe56c4e686a9c7f75..0000000000000000000000000000000000000000 --- a/profile/migrations/0002_auto_20220117_1721.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.2.25 on 2022-01-17 16:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("profile", "0001_squashed"), - ] - - operations = [ - migrations.AlterField( - model_name="profile", - name="cba_user_token", - field=models.CharField( - blank=True, max_length=255, verbose_name="CBA Token" - ), - ), - migrations.AlterField( - model_name="profile", - name="cba_username", - field=models.CharField( - blank=True, max_length=60, verbose_name="CBA Username" - ), - ), - ] diff --git a/profile/migrations/0003_auto_20171213_1737.py b/profile/migrations/0003_auto_20171213_1737.py deleted file mode 100644 index 8a4b11031e742c40aec3dd314efbf7e8018c19c0..0000000000000000000000000000000000000000 --- a/profile/migrations/0003_auto_20171213_1737.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-12-13 17:37 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("profile", "0002_auto_20171129_1828"), - ] - - operations = [ - migrations.RemoveField( - model_name="profile", - name="biography", - ), - migrations.RemoveField( - model_name="profile", - name="cba_url", - ), - migrations.RemoveField( - model_name="profile", - name="dorftv_url", - ), - migrations.RemoveField( - model_name="profile", - name="facebook_url", - ), - migrations.RemoveField( - model_name="profile", - name="googleplus_url", - ), - migrations.RemoveField( - model_name="profile", - name="height", - ), - migrations.RemoveField( - model_name="profile", - name="image", - ), - migrations.RemoveField( - model_name="profile", - name="linkedin_url", - ), - migrations.RemoveField( - model_name="profile", - name="ppoi", - ), - migrations.RemoveField( - model_name="profile", - name="twitter_url", - ), - migrations.RemoveField( - model_name="profile", - name="website", - ), - migrations.RemoveField( - model_name="profile", - name="width", - ), - migrations.RemoveField( - model_name="profile", - name="youtube_url", - ), - ] diff --git a/profile/migrations/__init__.py b/profile/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/profile/models.py b/profile/models.py deleted file mode 100644 index 743639222894e49eaadaf7539e780d9497a3ade0..0000000000000000000000000000000000000000 --- a/profile/models.py +++ /dev/null @@ -1,39 +0,0 @@ -# -# steering, Programme/schedule management for AURA -# -# Copyright (C) 2017-2018, Ingo Leindecker -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -# details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -# - -from django.contrib.auth.models import User -from django.db import models -from django.utils.translation import gettext_lazy as _ - - -class Profile(models.Model): - user = models.OneToOneField( - User, on_delete=models.CASCADE, related_name="profile", editable=False - ) - cba_username = models.CharField(_("CBA Username"), blank=True, max_length=60) - cba_user_token = models.CharField(_("CBA Token"), blank=True, max_length=255) - - def __str__(self): - return self.user.username - - class Meta: - db_table = "profile" - - def save(self, *args, **kwargs): - super(Profile, self).save(*args, **kwargs) diff --git a/profile/serializers.py b/profile/serializers.py deleted file mode 100644 index efff1c2f0ee98b5b086d3c5fa069cd8869a3b72c..0000000000000000000000000000000000000000 --- a/profile/serializers.py +++ /dev/null @@ -1,28 +0,0 @@ -# -# steering, Programme/schedule management for AURA -# -# Copyright (C) 2017-2018, Ingo Leindecker -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -# details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -# - -from profile.models import Profile - -from rest_framework import serializers - - -class ProfileSerializer(serializers.ModelSerializer): - class Meta: - model = Profile - fields = "__all__" diff --git a/program/admin.py b/program/admin.py index 3b7d2892dcf06f220a255db9f6d978fb69779700..d12297dba6abd6e9d5356e6596463ccffa41f3ed 100644 --- a/program/admin.py +++ b/program/admin.py @@ -1,12 +1,17 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User from program.models import ( Category, FundingCategory, Language, + LicenseType, + LinkType, MusicFocus, RRule, Topic, Type, + UserProfile, ) @@ -14,6 +19,10 @@ class AdminWithNameSlugIsActive(admin.ModelAdmin): list_display = ("name", "slug", "is_active") +class AdminWithNameType(admin.ModelAdmin): + list_display = ("name", "type") + + class LanguageAdmin(admin.ModelAdmin): list_display = ("name", "is_active") @@ -22,9 +31,55 @@ class RRuleAdmin(admin.ModelAdmin): list_display = ("name", "freq", "interval", "by_set_pos", "by_weekdays", "count") +class UserProfileInline(admin.StackedInline): + model = UserProfile + fields = ("cba_username", "cba_user_token") + can_delete = False + verbose_name_plural = "Profile" + fk_name = "user" + + +class UserProfileUserAdmin(UserAdmin): + inlines = (UserProfileInline,) + + def get_queryset(self, request): + """Let common users only edit their own profile""" + if not request.user.is_superuser: + return super(UserAdmin, self).get_queryset(request).filter(pk=request.user.id) + + return super(UserAdmin, self).get_queryset(request) + + def get_readonly_fields(self, request, obj=None): + """Limit field access for common users""" + if not request.user.is_superuser: + return ( + "username", + "is_staff", + "is_superuser", + "is_active", + "date_joined", + "last_login", + "groups", + "user_permissions", + ) + return list() + + def get_inline_instances(self, request, obj=None): + """Append profile fields to UserAdmin""" + if not obj: + return list() + + return super(UserProfileUserAdmin, self).get_inline_instances(request, obj) + + +admin.site.unregister(User) +admin.site.register(User, UserProfileUserAdmin) + admin.site.register(Category, AdminWithNameSlugIsActive) admin.site.register(FundingCategory, AdminWithNameSlugIsActive) admin.site.register(Language, LanguageAdmin) +admin.site.register(LinkType, AdminWithNameType) +admin.site.register(LicenseType, AdminWithNameType) admin.site.register(MusicFocus, AdminWithNameSlugIsActive) admin.site.register(RRule, RRuleAdmin) admin.site.register(Topic, AdminWithNameSlugIsActive) diff --git a/program/filters.py b/program/filters.py index b218b426a36c6693c1ba7c0fdc93e575361f77cd..298111c8b04302ad5ba4ca41981af0b80e6e563f 100644 --- a/program/filters.py +++ b/program/filters.py @@ -51,11 +51,13 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): field_name="hosts", help_text="Return only shows assigned to the given host(s).", ) - # TODO: replace `musicfocus` with `music_focus` when dashboard is updated - musicfocus = IntegerInFilter( + music_focus = IntegerInFilter( field_name="music_focus", help_text="Return only shows with given music focus(es).", ) + music_focus__slug = filters.CharFilter( + field_name="music_focus", help_text="Return only shows with the give music focus slug." + ) owner = IntegerInFilter( field_name="owners", help_text="Return only shows that belong to the given owner(s).", @@ -63,15 +65,24 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): category = IntegerInFilter( help_text="Return only shows of the given category or categories.", ) + category__slug = filters.CharFilter( + field_name="category", help_text="Return only shows of the given category slug." + ) language = IntegerInFilter( help_text="Return only shows of the given language(s).", ) topic = IntegerInFilter( help_text="Return only shows of the given topic(s).", ) + topic__slug = filters.CharFilter( + field_name="topic", help_text="Return only shows of the given topic slug." + ) type = IntegerInFilter( help_text="Return only shows of a given type.", ) + type__slug = filters.CharFilter( + field_name="type", help_text="Return only shows of the given type slug." + ) public = filters.BooleanFilter( field_name="is_public", help_text="Return only shows that are public/non-public.", @@ -121,7 +132,7 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): "category", "host", "language", - "musicfocus", + "music_focus", "owner", "public", "topic", @@ -220,27 +231,29 @@ class TimeSlotFilterSet(filters.FilterSet): class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): + show = IntegerInFilter( + field_name="timeslot__show", + help_text="Return only notes that belong to the specified show(s).", + ) + timeslot = IntegerInFilter( + field_name="timeslot", + help_text="Return only notes that belong to the specified timeslot(s).", + ) ids = IntegerInFilter( field_name="id", help_text="Return only notes matching the specified id(s).", ) - owner = IntegerInFilter( - field_name="show__owners", + show_owner = IntegerInFilter( + field_name="timeslot__show__owners", help_text="Return only notes by show the specified owner(s): all notes the user may edit.", ) class Meta: model = models.Note help_texts = { - "host": "Return only notes from the specified host.", - "user": "Return only notes created by the specified user.", + "owner": "Return only notes created by the specified user.", } - fields = [ - "host", - "ids", - "owner", - "user", - ] + fields = ["ids", "owner", "show", "timeslot", "show_owner"] class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): diff --git a/program/migrations/0023_auto_20220722_1747.py b/program/migrations/0023_auto_20220722_1747.py new file mode 100644 index 0000000000000000000000000000000000000000..2b5b68ce6ab8acea94ff9a57b2b772cf777df4ab --- /dev/null +++ b/program/migrations/0023_auto_20220722_1747.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.14 on 2022-07-22 15:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0022_auto_20220516_2245"), + ] + + operations = [ + migrations.RemoveField( + model_name="category", + name="abbrev", + ), + migrations.RemoveField( + model_name="fundingcategory", + name="abbrev", + ), + migrations.RemoveField( + model_name="musicfocus", + name="abbrev", + ), + migrations.RemoveField( + model_name="topic", + name="abbrev", + ), + ] diff --git a/program/migrations/0024_category_subtitle.py b/program/migrations/0024_category_subtitle.py new file mode 100644 index 0000000000000000000000000000000000000000..4c30430787b15b719f9b5b514530801cb84476af --- /dev/null +++ b/program/migrations/0024_category_subtitle.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-07-22 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0023_auto_20220722_1747"), + ] + + operations = [ + migrations.AddField( + model_name="category", + name="subtitle", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/program/migrations/0025_auto_20220728_1625.py b/program/migrations/0025_auto_20220728_1625.py new file mode 100644 index 0000000000000000000000000000000000000000..4253773240e31bc67f52d5d9d92430c81d518a17 --- /dev/null +++ b/program/migrations/0025_auto_20220728_1625.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.14 on 2022-07-28 14:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0024_category_subtitle"), + ] + + operations = [ + migrations.RemoveField( + model_name="timeslot", + name="is_repetition", + ), + migrations.AddField( + model_name="timeslot", + name="repetition_of", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="repetitions", + to="program.timeslot", + ), + ), + ] diff --git a/program/migrations/0026_auto_20220728_2227.py b/program/migrations/0026_auto_20220728_2227.py new file mode 100644 index 0000000000000000000000000000000000000000..3175410369f43716fbad5fd0cf3416168b0826d0 --- /dev/null +++ b/program/migrations/0026_auto_20220728_2227.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2022-07-28 20:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0025_auto_20220728_1625"), + ] + + operations = [ + migrations.RemoveField( + model_name="host", + name="website", + ), + migrations.RemoveField( + model_name="show", + name="website", + ), + ] diff --git a/program/migrations/0027_show_internal_note.py b/program/migrations/0027_show_internal_note.py new file mode 100644 index 0000000000000000000000000000000000000000..25434e5500df3ab4d39ebf7c59faf8da2fca56d7 --- /dev/null +++ b/program/migrations/0027_show_internal_note.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-08-01 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0026_auto_20220728_2227"), + ] + + operations = [ + migrations.AddField( + model_name="show", + name="internal_note", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/program/migrations/0028_auto_20220801_1713.py b/program/migrations/0028_auto_20220801_1713.py new file mode 100644 index 0000000000000000000000000000000000000000..05c1f1feb99ca1cc21f5c080774d1fc7083a8e46 --- /dev/null +++ b/program/migrations/0028_auto_20220801_1713.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-08-01 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0027_show_internal_note"), + ] + + operations = [ + migrations.AlterModelOptions( + name="language", + options={"ordering": ("name",)}, + ), + migrations.AlterField( + model_name="show", + name="language", + field=models.ManyToManyField( + blank=True, related_name="shows", to="program.Language" + ), + ), + ] diff --git a/program/migrations/0029_auto_20220801_2057.py b/program/migrations/0029_auto_20220801_2057.py new file mode 100644 index 0000000000000000000000000000000000000000..e6c5aa463c051b1390c6735977c5b5344ed66820 --- /dev/null +++ b/program/migrations/0029_auto_20220801_2057.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.14 on 2022-08-01 18:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0028_auto_20220801_1713"), + ] + + operations = [ + migrations.RemoveField( + model_name="note", + name="host", + ), + migrations.RemoveField( + model_name="note", + name="show", + ), + migrations.RemoveField( + model_name="note", + name="start", + ), + migrations.RemoveField( + model_name="note", + name="status", + ), + ] diff --git a/program/migrations/0030_auto_20220803_2217.py b/program/migrations/0030_auto_20220803_2217.py new file mode 100644 index 0000000000000000000000000000000000000000..83d3cf964045d849d59b7830bedb4962a055e0e1 --- /dev/null +++ b/program/migrations/0030_auto_20220803_2217.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.14 on 2022-08-03 20:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0029_auto_20220801_2057"), + ] + + operations = [ + migrations.AddField( + model_name="note", + name="contributors", + field=models.ManyToManyField( + related_name="contributions", to="program.Host" + ), + ), + migrations.AddField( + model_name="note", + name="tags", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/program/migrations/0031_auto_20220803_2226.py b/program/migrations/0031_auto_20220803_2226.py new file mode 100644 index 0000000000000000000000000000000000000000..e4072b02431cfb8751ee82b03e81c8589b9de3cd --- /dev/null +++ b/program/migrations/0031_auto_20220803_2226.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.14 on 2022-08-03 20:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("program", "0030_auto_20220803_2217"), + ] + + operations = [ + migrations.RemoveField( + model_name="note", + name="user", + ), + migrations.AddField( + model_name="note", + name="owner", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="notes", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/program/migrations/0032_auto_20220803_2312.py b/program/migrations/0032_auto_20220803_2312.py new file mode 100644 index 0000000000000000000000000000000000000000..fb5d6b915caefb69e4f3849b86e65c194ec23468 --- /dev/null +++ b/program/migrations/0032_auto_20220803_2312.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.14 on 2022-08-03 21:12 + +import versatileimagefield.fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0031_auto_20220803_2226"), + ] + + operations = [ + migrations.RenameField( + model_name="host", + old_name="height", + new_name="image_height", + ), + migrations.RenameField( + model_name="host", + old_name="ppoi", + new_name="image_ppoi", + ), + migrations.RenameField( + model_name="host", + old_name="width", + new_name="image_width", + ), + migrations.AddField( + model_name="host", + name="image_alt_text", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="host", + name="image_credits", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="host", + name="image", + field=versatileimagefield.fields.VersatileImageField( + blank=True, + height_field="image_height", + null=True, + upload_to="images", + width_field="image_width", + ), + ), + ] diff --git a/program/migrations/0033_auto_20220803_2331.py b/program/migrations/0033_auto_20220803_2331.py new file mode 100644 index 0000000000000000000000000000000000000000..2fd4b3ecd2b8da4a15a8c8281d7d72f151134c61 --- /dev/null +++ b/program/migrations/0033_auto_20220803_2331.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.14 on 2022-08-03 21:31 + +import versatileimagefield.fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0032_auto_20220803_2312"), + ] + + operations = [ + migrations.RenameField( + model_name="show", + old_name="height", + new_name="image_height", + ), + migrations.RenameField( + model_name="show", + old_name="ppoi", + new_name="image_ppoi", + ), + migrations.RenameField( + model_name="show", + old_name="width", + new_name="image_width", + ), + migrations.AddField( + model_name="show", + name="image_alt_text", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="show", + name="image_credits", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="show", + name="image", + field=versatileimagefield.fields.VersatileImageField( + blank=True, + height_field="image_height", + null=True, + upload_to="images", + width_field="image_width", + ), + ), + ] diff --git a/program/migrations/0034_auto_20220803_2336.py b/program/migrations/0034_auto_20220803_2336.py new file mode 100644 index 0000000000000000000000000000000000000000..877286968cac9e3f544feff9a6de0abd8b392657 --- /dev/null +++ b/program/migrations/0034_auto_20220803_2336.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.14 on 2022-08-03 21:36 + +import versatileimagefield.fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0033_auto_20220803_2331"), + ] + + operations = [ + migrations.RenameField( + model_name="note", + old_name="height", + new_name="image_height", + ), + migrations.RenameField( + model_name="note", + old_name="ppoi", + new_name="image_ppoi", + ), + migrations.RenameField( + model_name="note", + old_name="width", + new_name="image_width", + ), + migrations.AddField( + model_name="note", + name="image_alt_text", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="note", + name="image_credits", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="note", + name="image", + field=versatileimagefield.fields.VersatileImageField( + blank=True, + height_field="image_height", + null=True, + upload_to="images", + width_field="image_width", + ), + ), + ] diff --git a/program/migrations/0035_auto_20220807_2312.py b/program/migrations/0035_auto_20220807_2312.py new file mode 100644 index 0000000000000000000000000000000000000000..b7caec3af006bd02090d711aca2029949b9c7b33 --- /dev/null +++ b/program/migrations/0035_auto_20220807_2312.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.14 on 2022-08-07 21:12 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0034_auto_20220803_2336"), + ] + + operations = [ + migrations.AddField( + model_name="host", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="host", + name="created_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + migrations.AddField( + model_name="host", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="host", + name="updated_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + ] diff --git a/program/migrations/0036_auto_20220807_2318.py b/program/migrations/0036_auto_20220807_2318.py new file mode 100644 index 0000000000000000000000000000000000000000..9070d28c567019f5fabad9026c86aa4c2e189e70 --- /dev/null +++ b/program/migrations/0036_auto_20220807_2318.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.14 on 2022-08-07 21:18 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0035_auto_20220807_2312"), + ] + + operations = [ + migrations.AddField( + model_name="show", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="show", + name="created_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + migrations.AddField( + model_name="show", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="show", + name="updated_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + ] diff --git a/program/migrations/0037_auto_20220807_2321.py b/program/migrations/0037_auto_20220807_2321.py new file mode 100644 index 0000000000000000000000000000000000000000..dafff82f1f2f6f3823e1d57b7dbb7129f2328e73 --- /dev/null +++ b/program/migrations/0037_auto_20220807_2321.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.14 on 2022-08-07 21:21 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0036_auto_20220807_2318"), + ] + + operations = [ + migrations.AddField( + model_name="note", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="note", + name="created_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + migrations.AddField( + model_name="note", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="note", + name="updated_by", + field=models.CharField(default="root", max_length=150), + preserve_default=False, + ), + ] diff --git a/program/migrations/0038_auto_20220817_2132.py b/program/migrations/0038_auto_20220817_2132.py new file mode 100644 index 0000000000000000000000000000000000000000..1c6ace5fe149f1b7f81e69c27bd7285d0ad4bfd8 --- /dev/null +++ b/program/migrations/0038_auto_20220817_2132.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.15 on 2022-08-17 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0037_auto_20220807_2321"), + ] + + operations = [ + migrations.AlterField( + model_name="host", + name="updated_at", + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name="host", + name="updated_by", + field=models.CharField(blank=True, max_length=150, null=True), + ), + migrations.AlterField( + model_name="note", + name="updated_at", + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name="note", + name="updated_by", + field=models.CharField(blank=True, max_length=150, null=True), + ), + migrations.AlterField( + model_name="show", + name="updated_at", + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name="show", + name="updated_by", + field=models.CharField(blank=True, max_length=150, null=True), + ), + ] diff --git a/program/migrations/0039_auto_20221002_2307.py b/program/migrations/0039_auto_20221002_2307.py new file mode 100644 index 0000000000000000000000000000000000000000..f06d3e562774548d613511241006a1cae0fe63c0 --- /dev/null +++ b/program/migrations/0039_auto_20221002_2307.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.15 on 2022-10-02 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0038_auto_20220817_2132"), + ] + + operations = [ + migrations.RemoveField( + model_name="hostlink", + name="description", + ), + migrations.RemoveField( + model_name="notelink", + name="description", + ), + migrations.RemoveField( + model_name="showlink", + name="description", + ), + migrations.AddField( + model_name="hostlink", + name="type", + field=models.CharField(default="Link", max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name="notelink", + name="type", + field=models.CharField(default="Link", max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name="showlink", + name="type", + field=models.CharField(default="Link", max_length=32), + preserve_default=False, + ), + ] diff --git a/program/migrations/0040_linktype.py b/program/migrations/0040_linktype.py new file mode 100644 index 0000000000000000000000000000000000000000..3e775313b50b81e0cd34d6c86c22a524881b6115 --- /dev/null +++ b/program/migrations/0040_linktype.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.15 on 2022-10-02 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0039_auto_20221002_2307"), + ] + + operations = [ + migrations.CreateModel( + name="LinkType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(help_text="Name of the link type", max_length=16)), + ("type", models.CharField(help_text="Type of the link", max_length=32)), + ], + options={ + "ordering": ("name",), + }, + ), + ] diff --git a/program/migrations/0041_licensetype.py b/program/migrations/0041_licensetype.py new file mode 100644 index 0000000000000000000000000000000000000000..ff2521bfa14dc8528f2e638764d05189a1f00ac9 --- /dev/null +++ b/program/migrations/0041_licensetype.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.15 on 2022-10-02 21:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0040_linktype"), + ] + + operations = [ + migrations.CreateModel( + name="LicenseType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(help_text="Name of the license type", max_length=16)), + ("type", models.CharField(help_text="Type of the license", max_length=64)), + ], + options={ + "ordering": ("name",), + }, + ), + ] diff --git a/program/migrations/0042_userprofile.py b/program/migrations/0042_userprofile.py new file mode 100644 index 0000000000000000000000000000000000000000..27fb6012ee7690bbae797dafd7c7ac56fa347ac7 --- /dev/null +++ b/program/migrations/0042_userprofile.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.16 on 2022-10-11 22:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("program", "0041_licensetype"), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("created_by", models.CharField(max_length=150)), + ("updated_at", models.DateTimeField(auto_now=True, null=True)), + ("updated_by", models.CharField(blank=True, max_length=150, null=True)), + ( + "cba_username", + models.CharField(blank=True, max_length=60, verbose_name="CBA Username"), + ), + ( + "cba_user_token", + models.CharField(blank=True, max_length=255, verbose_name="CBA Token"), + ), + ( + "user", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/program/migrations/0043_note_playlist.py b/program/migrations/0043_note_playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..52b126e0398152f1da393de3ffeadb2434e71e61 --- /dev/null +++ b/program/migrations/0043_note_playlist.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-10-19 19:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0042_userprofile"), + ] + + operations = [ + migrations.AddField( + model_name="note", + name="playlist", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/program/migrations/0044_alter_linktype_type.py b/program/migrations/0044_alter_linktype_type.py new file mode 100644 index 0000000000000000000000000000000000000000..163060fbf8dd0f493af2de3b5eeb77d46ff7eb63 --- /dev/null +++ b/program/migrations/0044_alter_linktype_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-10-19 21:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0043_note_playlist"), + ] + + operations = [ + migrations.AlterField( + model_name="linktype", + name="type", + field=models.CharField(help_text="Type of the link", max_length=64), + ), + ] diff --git a/program/migrations/0045_auto_20221021_2008.py b/program/migrations/0045_auto_20221021_2008.py new file mode 100644 index 0000000000000000000000000000000000000000..7fec78e26cb5530aae80d7e9330ee2f55dbb0e9e --- /dev/null +++ b/program/migrations/0045_auto_20221021_2008.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.16 on 2022-10-21 18:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0044_alter_linktype_type"), + ] + + operations = [ + migrations.AlterField( + model_name="hostlink", + name="type", + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name="notelink", + name="type", + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name="showlink", + name="type", + field=models.CharField(max_length=64), + ), + ] diff --git a/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py b/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py new file mode 100644 index 0000000000000000000000000000000000000000..68c0513fa867879c806071e66852a05382e06451 --- /dev/null +++ b/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.18 on 2023-03-27 15:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0025_auto_20230326_2211'), + ('program', '0045_auto_20221021_2008'), + ] + + operations = [ + ] diff --git a/program/migrations/0047_image.py b/program/migrations/0047_image.py new file mode 100644 index 0000000000000000000000000000000000000000..92c1d5fb87be122cc0fdaa9e186a0443f0d88073 --- /dev/null +++ b/program/migrations/0047_image.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.18 on 2023-03-29 01:33 + +import versatileimagefield.fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program", "0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008"), + ] + + operations = [ + migrations.CreateModel( + name="Image", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("alt_text", models.TextField(blank=True, null=True)), + ("credits", models.TextField(blank=True, null=True)), + ("height", models.PositiveIntegerField(blank=True, editable=False, null=True)), + ( + "image", + versatileimagefield.fields.VersatileImageField( + blank=True, + height_field="height", + null=True, + upload_to="images", + width_field="width", + ), + ), + ("owner", models.CharField(max_length=150)), + ( + "ppoi", + versatileimagefield.fields.PPOIField( + default="0.5x0.5", editable=False, max_length=20 + ), + ), + ("width", models.PositiveIntegerField(blank=True, editable=False, null=True)), + ], + ), + ] diff --git a/program/migrations/0048_auto_20230403_2228.py b/program/migrations/0048_auto_20230403_2228.py new file mode 100644 index 0000000000000000000000000000000000000000..88b38cfb3888867d7d0d1b51cb2f8317aea7f949 --- /dev/null +++ b/program/migrations/0048_auto_20230403_2228.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.18 on 2023-04-03 20:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program", "0047_image"), + ] + + operations = [ + migrations.RemoveField( + model_name="host", + name="image_alt_text", + ), + migrations.RemoveField( + model_name="host", + name="image_credits", + ), + migrations.RemoveField( + model_name="host", + name="image_height", + ), + migrations.RemoveField( + model_name="host", + name="image_ppoi", + ), + migrations.RemoveField( + model_name="host", + name="image_width", + ), + migrations.RemoveField(model_name="host", name="image"), + migrations.AddField( + model_name="host", + name="image", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="hosts", + to="program.image", + ), + ), + ] diff --git a/program/migrations/0049_auto_20230404_0020.py b/program/migrations/0049_auto_20230404_0020.py new file mode 100644 index 0000000000000000000000000000000000000000..27e28e6c0ae999629ac6e627d72d50c3037fb141 --- /dev/null +++ b/program/migrations/0049_auto_20230404_0020.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.18 on 2023-04-03 22:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program", "0048_auto_20230403_2228"), + ] + + operations = [ + migrations.RemoveField( + model_name="note", + name="image_alt_text", + ), + migrations.RemoveField( + model_name="note", + name="image_credits", + ), + migrations.RemoveField( + model_name="note", + name="image_height", + ), + migrations.RemoveField( + model_name="note", + name="image_ppoi", + ), + migrations.RemoveField( + model_name="note", + name="image_width", + ), + migrations.RemoveField(model_name="note", name="image"), + migrations.AddField( + model_name="note", + name="image", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notes", + to="program.image", + ), + ), + ] diff --git a/program/migrations/0050_auto_20230404_0037.py b/program/migrations/0050_auto_20230404_0037.py new file mode 100644 index 0000000000000000000000000000000000000000..2e13018a67d05129a5fb056418c437d156103eb4 --- /dev/null +++ b/program/migrations/0050_auto_20230404_0037.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.18 on 2023-04-03 22:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program", "0049_auto_20230404_0020"), + ] + + operations = [ + migrations.RemoveField( + model_name="show", + name="image_alt_text", + ), + migrations.RemoveField( + model_name="show", + name="image_credits", + ), + migrations.RemoveField( + model_name="show", + name="image_height", + ), + migrations.RemoveField( + model_name="show", + name="image_ppoi", + ), + migrations.RemoveField( + model_name="show", + name="image_width", + ), + migrations.RemoveField(model_name="show", name="image"), + migrations.AddField( + model_name="show", + name="image", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="shows", + to="program.image", + ), + ), + ] diff --git a/program/migrations/0051_remove_show_logo.py b/program/migrations/0051_remove_show_logo.py new file mode 100644 index 0000000000000000000000000000000000000000..9130f374d3cd8df948d146b08e325c698d3befe2 --- /dev/null +++ b/program/migrations/0051_remove_show_logo.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.18 on 2023-04-11 15:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0050_auto_20230404_0037'), + ] + + operations = [ + migrations.RemoveField( + model_name='show', + name='logo', + ), + ] diff --git a/program/migrations/0052_show_logo.py b/program/migrations/0052_show_logo.py new file mode 100644 index 0000000000000000000000000000000000000000..ba7a4c67358e11a8b341cc714d325a4bba3bbf4d --- /dev/null +++ b/program/migrations/0052_show_logo.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-11 15:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0051_remove_show_logo'), + ] + + operations = [ + migrations.AddField( + model_name='show', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='logo_shows', to='program.image'), + ), + ] diff --git a/program/migrations/0053_auto_20230411_1855.py b/program/migrations/0053_auto_20230411_1855.py new file mode 100644 index 0000000000000000000000000000000000000000..6a9ad1de44ffd19cfb123d0efc4f3fb844229e6e --- /dev/null +++ b/program/migrations/0053_auto_20230411_1855.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-04-11 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0052_show_logo'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='alt_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='image', + name='credits', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/program/models.py b/program/models.py index 5457ea2c51b72b23753b0881d5939abb066effdc..15ff88631c07996c03bf95c54718d591b0ab0dfd 100644 --- a/program/models.py +++ b/program/models.py @@ -18,26 +18,17 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # -from datetime import datetime, time, timedelta +from datetime import datetime -from dateutil.relativedelta import relativedelta -from dateutil.rrule import rrule from rest_framework.exceptions import ValidationError from versatileimagefield.fields import PPOIField, VersatileImageField from django.contrib.auth.models import User -from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Q, QuerySet -from django.forms.models import model_to_dict -from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from program.utils import parse_date, parse_datetime, parse_time -from steering.settings import ( - AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE, - AUTO_SET_LAST_DATE_TO_END_OF_YEAR, - THUMBNAIL_SIZES, -) +from program.utils import parse_datetime +from steering.settings import THUMBNAIL_SIZES class ScheduleConflictError(ValidationError): @@ -47,9 +38,9 @@ class ScheduleConflictError(ValidationError): class Type(models.Model): + is_active = models.BooleanField(default=True) name = models.CharField(max_length=32) slug = models.SlugField(max_length=32, unique=True) - is_active = models.BooleanField(default=True) class Meta: ordering = ("name",) @@ -59,11 +50,11 @@ class Type(models.Model): class Category(models.Model): + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) name = models.CharField(max_length=32) - abbrev = models.CharField(max_length=4, unique=True) slug = models.SlugField(max_length=32, unique=True) - is_active = models.BooleanField(default=True) - description = models.TextField(blank=True) + subtitle = models.TextField(blank=True, null=True) class Meta: ordering = ("name",) @@ -74,10 +65,9 @@ class Category(models.Model): class Topic(models.Model): + is_active = models.BooleanField(default=True) name = models.CharField(max_length=32) - abbrev = models.CharField(max_length=4, unique=True) slug = models.SlugField(max_length=32, unique=True) - is_active = models.BooleanField(default=True) class Meta: ordering = ("name",) @@ -87,10 +77,9 @@ class Topic(models.Model): class MusicFocus(models.Model): + is_active = models.BooleanField(default=True) name = models.CharField(max_length=32) - abbrev = models.CharField(max_length=4, unique=True) slug = models.SlugField(max_length=32, unique=True) - is_active = models.BooleanField(default=True) class Meta: ordering = ("name",) @@ -101,10 +90,9 @@ class MusicFocus(models.Model): class FundingCategory(models.Model): + is_active = models.BooleanField(default=True) name = models.CharField(max_length=32) - abbrev = models.CharField(max_length=4, unique=True) slug = models.SlugField(max_length=32, unique=True) - is_active = models.BooleanField(default=True) class Meta: ordering = ("name",) @@ -115,33 +103,56 @@ class FundingCategory(models.Model): class Language(models.Model): - name = models.CharField(max_length=32) is_active = models.BooleanField(default=True) + name = models.CharField(max_length=32) class Meta: - ordering = ("language",) + ordering = ("name",) def __str__(self): return self.name -class Host(models.Model): - name = models.CharField(max_length=128) - is_active = models.BooleanField(default=True) - email = models.EmailField(blank=True) - website = models.URLField(blank=True) - biography = models.TextField(blank=True, null=True) - ppoi = PPOIField() +class Image(models.Model): + alt_text = models.TextField(blank=True, default="") + credits = models.TextField(blank=True, default="") height = models.PositiveIntegerField(blank=True, null=True, editable=False) - width = models.PositiveIntegerField(blank=True, null=True, editable=False) image = VersatileImageField( blank=True, - null=True, - upload_to="host_images", - width_field="width", height_field="height", + null=True, ppoi_field="ppoi", + upload_to="images", + width_field="width", ) + owner = models.CharField(max_length=150) + ppoi = PPOIField() + width = models.PositiveIntegerField(blank=True, null=True, editable=False) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + if self.image.name and THUMBNAIL_SIZES: + for size in THUMBNAIL_SIZES: + self.image.thumbnail = self.image.crop[size].name + + def delete(self, using=None, keep_parents=False): + self.image.delete_all_created_images() + self.image.delete(save=False) + + super().delete(using, keep_parents) + + +class Host(models.Model): + biography = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=150) + email = models.EmailField(blank=True) + image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="hosts") + is_active = models.BooleanField(default=True) + name = models.CharField(max_length=128) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + updated_by = models.CharField(blank=True, max_length=150, null=True) class Meta: ordering = ("name",) @@ -149,17 +160,20 @@ class Host(models.Model): def __str__(self): return self.name - def save(self, *args, **kwargs): - super(Host, self).save(*args, **kwargs) - # Generate thumbnails - if self.image.name and THUMBNAIL_SIZES: - for size in THUMBNAIL_SIZES: - self.image.thumbnail = self.image.crop[size].name +class LinkType(models.Model): + name = models.CharField(max_length=16, help_text="Name of the link type") + type = models.CharField(max_length=64, help_text="Type of the link") + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.type class Link(models.Model): - description = models.CharField(max_length=16) + type = models.CharField(max_length=64) url = models.URLField() class Meta: @@ -173,21 +187,25 @@ class HostLink(Link): host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="links") +class LicenseType(models.Model): + name = models.CharField(max_length=16, help_text="Name of the license type") + type = models.CharField(max_length=64, help_text="Type of the license") + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.type + + class Show(models.Model): - predecessor = models.ForeignKey( - "self", - blank=True, - null=True, - on_delete=models.CASCADE, - related_name="successors", - ) - hosts = models.ManyToManyField(Host, blank=True, related_name="shows") - owners = models.ManyToManyField(User, blank=True, related_name="shows") - language = models.ManyToManyField(Language, blank=True, related_name="language") - type = models.ForeignKey( - Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows" - ) category = models.ManyToManyField(Category, blank=True, related_name="shows") + cba_series_id = models.IntegerField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=150) + default_playlist_id = models.IntegerField(blank=True, null=True) + description = models.TextField(blank=True, null=True) + email = models.EmailField(blank=True, null=True) funding_category = models.ForeignKey( FundingCategory, null=True, @@ -195,30 +213,38 @@ class Show(models.Model): blank=True, related_name="shows", ) - topic = models.ManyToManyField(Topic, blank=True, related_name="shows") + hosts = models.ManyToManyField(Host, blank=True, related_name="shows") + image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="shows") + internal_note = models.TextField(blank=True, null=True) + is_active = models.BooleanField(default=True) + is_public = models.BooleanField(default=False) + language = models.ManyToManyField(Language, blank=True, related_name="shows") + # TODO: is this really necessary? + logo = models.ForeignKey( + Image, + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="logo_shows", + ) music_focus = models.ManyToManyField(MusicFocus, blank=True, related_name="shows") name = models.CharField(max_length=255) - slug = models.CharField(max_length=255, unique=True) - ppoi = PPOIField() - height = models.PositiveIntegerField(blank=True, null=True, editable=False) - width = models.PositiveIntegerField(blank=True, null=True, editable=False) - image = VersatileImageField( + owners = models.ManyToManyField(User, blank=True, related_name="shows") + predecessor = models.ForeignKey( + "self", blank=True, null=True, - upload_to="show_images", - width_field="width", - height_field="height", - ppoi_field="ppoi", + on_delete=models.CASCADE, + related_name="successors", ) - logo = models.ImageField(blank=True, null=True, upload_to="show_images") short_description = models.TextField() - description = models.TextField(blank=True, null=True) - email = models.EmailField(blank=True, null=True) - website = models.URLField(blank=True, null=True) - cba_series_id = models.IntegerField(blank=True, null=True) - default_playlist_id = models.IntegerField(blank=True, null=True) - is_active = models.BooleanField(default=True) - is_public = models.BooleanField(default=False) + slug = models.CharField(max_length=255, unique=True) + topic = models.ManyToManyField(Topic, blank=True, related_name="shows") + type = models.ForeignKey( + Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows" + ) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + updated_by = models.CharField(blank=True, max_length=150, null=True) class Meta: ordering = ("slug",) @@ -232,19 +258,6 @@ class ShowLink(Link): class RRule(models.Model): - name = models.CharField(max_length=32, unique=True) - freq = models.IntegerField( - choices=[ - (0, "once"), - (1, "monthly"), - (2, "weekly"), - (3, "daily"), - ] - ) - interval = models.IntegerField( - default=1, - help_text="The interval between each freq iteration.", - ) by_set_pos = models.IntegerField( blank=True, choices=[ @@ -272,6 +285,19 @@ class RRule(models.Model): null=True, help_text="How many occurrences should be generated.", ) + freq = models.IntegerField( + choices=[ + (0, "once"), + (1, "monthly"), + (2, "weekly"), + (3, "daily"), + ] + ) + interval = models.IntegerField( + default=1, + help_text="The interval between each freq iteration.", + ) + name = models.CharField(max_length=32, unique=True) class Meta: ordering = ("pk",) @@ -283,17 +309,20 @@ class RRule(models.Model): class Schedule(models.Model): - rrule = models.ForeignKey( - RRule, - on_delete=models.CASCADE, - related_name="schedules", - help_text="A recurrence rule.", + add_business_days_only = models.BooleanField( + default=False, + help_text=( + "Whether to add add_days_no but skipping the weekends. " + "E.g. if weekday is Friday, the date returned will be the next Monday." + ), ) - show = models.ForeignKey( - Show, - on_delete=models.CASCADE, - related_name="schedules", - help_text="Show the schedule belongs to.", + add_days_no = models.IntegerField( + blank=True, + null=True, + help_text=( + "Add a number of days to the generated dates. " + "This can be useful for repetitions, like 'On the following day'." + ), ) by_weekday = models.IntegerField( help_text="Number of the Weekday.", @@ -308,719 +337,35 @@ class Schedule(models.Model): ], null=True, ) - first_date = models.DateField(help_text="Start date of schedule.") - start_time = models.TimeField(help_text="Start time of schedule.") + default_playlist_id = models.IntegerField( + blank=True, + null=True, + help_text="A tank ID in case the timeslot's playlist_id is empty.", + ) end_time = models.TimeField(help_text="End time of schedule.") - last_date = models.DateField(help_text="End date of schedule.") + first_date = models.DateField(help_text="Start date of schedule.") is_repetition = models.BooleanField( default=False, help_text="Whether the schedule is a repetition.", ) - add_days_no = models.IntegerField( - blank=True, - null=True, - help_text=( - "Add a number of days to the generated dates. " - "This can be useful for repetitions, like 'On the following day'." - ), - ) - add_business_days_only = models.BooleanField( - default=False, - help_text=( - "Whether to add add_days_no but skipping the weekends. " - "E.g. if weekday is Friday, the date returned will be the next Monday." - ), + last_date = models.DateField(help_text="End date of schedule.") + rrule = models.ForeignKey( + RRule, + on_delete=models.CASCADE, + related_name="schedules", + help_text="A recurrence rule.", ) - default_playlist_id = models.IntegerField( - blank=True, - null=True, - help_text="A tank ID in case the timeslot's playlist_id is empty.", + show = models.ForeignKey( + Show, + on_delete=models.CASCADE, + related_name="schedules", + help_text="Show the schedule belongs to.", ) + start_time = models.TimeField(help_text="Start time of schedule.") class Meta: ordering = ("first_date", "start_time") - # FIXME: this does not belong here - @staticmethod - def instantiate_upcoming(sdl, show_pk, pk=None): - """Returns an upcoming schedule instance for conflict resolution""" - pk = int(pk) if pk is not None else None - rrule = RRule.objects.get(pk=int(sdl["rrule"])) - show = Show.objects.get(pk=int(show_pk)) - - is_repetition = True if sdl.get("is_repetition") is True else False - default_playlist_id = ( - int(sdl["default_playlist_id"]) if sdl.get("default_playlist_id") else None - ) - add_days_no = int(sdl["add_days_no"]) if sdl.get("add_days_no") else None - add_business_days_only = True if sdl.get("add_business_days_only") is True else False - - first_date = parse_date(str(sdl["first_date"])) - start_time = ( - sdl["start_time"] + ":00" if len(str(sdl["start_time"])) == 5 else sdl["start_time"] - ) - end_time = sdl["end_time"] + ":00" if len(str(sdl["end_time"])) == 5 else sdl["end_time"] - - start_time = parse_time(str(start_time)) - end_time = parse_time(str(end_time)) - - if sdl["last_date"]: - last_date = parse_date(str(sdl["last_date"])) - else: - # If last_date was not set, set it to the end of the year or add x days - if AUTO_SET_LAST_DATE_TO_END_OF_YEAR: - year = timezone.now().year - last_date = parse_date(f"{year}-12-31") - else: - last_date = first_date + timedelta(days=+AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE) - - schedule = Schedule( - pk=pk, - by_weekday=sdl["by_weekday"], - rrule=rrule, - first_date=first_date, - start_time=start_time, - end_time=end_time, - last_date=last_date, - is_repetition=is_repetition, - default_playlist_id=default_playlist_id, - show=show, - add_days_no=add_days_no, - add_business_days_only=add_business_days_only, - ) - - return schedule - - # FIXME: this does not belong here - @staticmethod - def generate_timeslots(schedule): - """ - Returns a list of timeslot objects based on a schedule and its rrule - Returns past timeslots as well, starting from first_date (not today) - """ - timeslots = [] - - # adjust last_date if end_time is after midnight - if schedule.end_time < schedule.start_time: - last_date = schedule.first_date + timedelta(days=+1) - else: - last_date = schedule.first_date - - if schedule.rrule.freq == 3: # daily: Ignore schedule.by_weekday to set by_weekday - by_weekday_start = by_weekday_end = (0, 1, 2, 3, 4, 5, 6) - elif ( - schedule.rrule.freq == 2 - and schedule.rrule.interval == 1 - and schedule.rrule.by_weekdays is None - ): # weekly: Use schedule.by_weekday for by_weekday - by_weekday_start = by_weekday_end = int(schedule.by_weekday) - - # adjust by_weekday_end if end_time is after midnight - if schedule.end_time < schedule.start_time: - by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0 - elif ( - schedule.rrule.freq == 2 - and schedule.rrule.interval == 1 - and schedule.rrule.by_weekdays == "0,1,2,3,4" - ): # weekly on business days: Use schedule.rrule.by_weekdays to set by_weekday - by_weekday_start = by_weekday_end = [ - int(wd) for wd in schedule.rrule.by_weekdays.split(",") - ] - - # adjust by_weekday_end if end_time is after midnight - if schedule.end_time < schedule.start_time: - by_weekday_end = (1, 2, 3, 4, 5) - elif ( - schedule.rrule.freq == 2 - and schedule.rrule.interval == 1 - and schedule.rrule.by_weekdays == "5,6" - ): # weekly on weekends: Use schedule.rrule.by_weekdays to set by_weekday - by_weekday_start = by_weekday_end = [ - int(wd) for wd in schedule.rrule.by_weekdays.split(",") - ] - - # adjust by_weekday_end if end_time is after midnight - if schedule.end_time < schedule.start_time: - by_weekday_end = (6, 0) - elif schedule.rrule.freq == 0: # once: Ignore schedule.by_weekday to set by_weekday - by_weekday_start = by_weekday_end = None - else: - by_weekday_start = by_weekday_end = ( - int(schedule.by_weekday) if schedule.by_weekday is not None else None - ) - - # adjust by_weekday_end if end_time is after midnight - if schedule.end_time < schedule.start_time: - by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0 - - if schedule.rrule.freq == 0: # once: - starts = [datetime.combine(schedule.first_date, schedule.start_time)] - ends = [datetime.combine(last_date, schedule.end_time)] - else: - starts = list( - rrule( - freq=schedule.rrule.freq, - dtstart=datetime.combine(schedule.first_date, schedule.start_time), - interval=schedule.rrule.interval, - until=schedule.last_date + relativedelta(days=+1), - bysetpos=schedule.rrule.by_set_pos, - byweekday=by_weekday_start, - ) - ) - ends = list( - rrule( - freq=schedule.rrule.freq, - dtstart=datetime.combine(last_date, schedule.end_time), - interval=schedule.rrule.interval, - until=schedule.last_date + relativedelta(days=+1), - bysetpos=schedule.rrule.by_set_pos, - byweekday=by_weekday_end, - ) - ) - - for k in range(min(len(starts), len(ends))): - # Correct dates for the (relatively seldom) case if: - # E.g.: 1st Monday from 23:00:00 to 1st Tuesday 00:00:00 - # produces wrong end dates if the 1st Tuesday is before the 1st Monday - # In this case we take the next day instead of rrule's calculated end - if starts[k] > ends[k]: - ends[k] = datetime.combine(starts[k] + relativedelta(days=+1), schedule.end_time) - - """ - Add a number of days to the generated dates? - - This can be helpful for repetitions: - - Examples: - - 1. If RRule is "Every 1st Monday" and we want its repetition alyways to be on the - following day, the repetition's RRule is the same but add_days_no is 1 - - If we would set the repetition to "Every 1st Tuesday" instead - we will get unmeant results if the 1st Tuesday is before the 1st Monday - (e.g. 1st Tue = May 1 2018, 1st Mon = May 7 2018) - - 2. If RRule is "Every 1st Friday" and we want its repetition always to be on the - following business day, the repetition's RRule is the same but add_days_no is 1 - and add_business_days_only is True (e.g. original date = Fri, March 2 2018; - generated date = Mon, March 5 2018) - - In the UI these can be presets: - "On the following day" (add_days_no=1,add_business_days_only=False) or - "On the following business day" (add_days_no=1,add_business_days_only=True) - - """ - if schedule.add_days_no is not None and schedule.add_days_no > 0: - # If only business days and weekday is Fri, Sat or Sun: add add_days_no beginning - # from Sunday - weekday = datetime.date(starts[k]).weekday() - if schedule.add_business_days_only and weekday > 3: - days_until_sunday = 6 - weekday - starts[k] = starts[k] + relativedelta( - days=+days_until_sunday + schedule.add_days_no - ) - ends[k] = ends[k] + relativedelta( - days=+days_until_sunday + schedule.add_days_no - ) - else: - starts[k] = starts[k] + relativedelta(days=+schedule.add_days_no) - ends[k] = ends[k] + relativedelta(days=+schedule.add_days_no) - - if ends[k].date() > schedule.last_date: - schedule.last_date = ends[k].date() - timeslots.append( - TimeSlot( - schedule=schedule, - start=timezone.make_aware(starts[k], is_dst=True), - end=timezone.make_aware(ends[k], is_dst=True), - ) - ) - - return timeslots - - # FIXME: this does not belong here - @staticmethod - def get_collisions(timeslots): - """ - Tests a list of timeslot objects for colliding timeslots in the database - Returns a list of collisions, containing colliding timeslot IDs or None - Keeps indices from input list for later comparison - """ - - collisions = [] - - for ts in timeslots: - collision = TimeSlot.objects.get_colliding_timeslots(ts) - - if collision: - collisions.append(collision[0]) # TODO: Do we really always retrieve one? - else: - collisions.append(None) - - return collisions - - # FIXME: this does not belong here - @staticmethod - def generate_conflicts(timeslots): - """ - Tests a list of timeslot objects for colliding timeslots in the database - Returns a list of conflicts containing dicts of projected timeslots, collisions and - solutions - """ - - conflicts = {} - projected = [] - solutions = {} - - # Cycle each timeslot - for ts in timeslots: - # Contains collisions - collisions = [] - - # Contains possible solutions - solution_choices = set() - - # Get collisions for each timeslot - collision_list = list(TimeSlot.objects.get_colliding_timeslots(ts).order_by("start")) - - # Add the projected timeslot - projected_entry = { - "hash": ts.hash, - "start": str(ts.start), - "end": str(ts.end), - } - - for c in collision_list: - # Add the collision - collision = { - "id": c.id, - "start": str(c.start), - "end": str(c.end), - "playlist_id": c.playlist_id, - "show": c.show.id, - "show_name": c.show.name, - "is_repetition": c.is_repetition, - "schedule": c.schedule_id, - "memo": c.memo, - } - - # Get note - try: - note = Note.objects.get(timeslot=c.id) - collision["note_id"] = note.pk - except ObjectDoesNotExist: - collision["note_id"] = None - - collisions.append(collision) - - """Determine acceptable solutions""" - - if len(collision_list) > 1: - # If there is more than one collision: Only these two are supported at the - # moment - solution_choices.add("theirs") - solution_choices.add("ours") - else: - # These two are always possible: Either keep theirs and remove ours or vice - # versa - solution_choices.add("theirs") - solution_choices.add("ours") - - # Partly overlapping: projected starts earlier than existing and ends earlier - # - # ex. pr. - # +--+ - # | | - # +--+ | | - # | | +--+ - # | | - # +--+ - # - if ts.end > c.start > ts.start <= c.end: - solution_choices.add("theirs-end") - solution_choices.add("ours-end") - - # Partly overlapping: projected starts later than existing and ends later - # - # ex. pr. - # +--+ - # | | - # | | +--+ - # +--+ | | - # | | - # +--+ - # - if c.start <= ts.start < c.end < ts.end: - solution_choices.add("theirs-start") - solution_choices.add("ours-start") - - # Fully overlapping: projected starts earlier and ends later than existing - # - # ex. pr. - # +--+ - # +--+ | | - # | | | | - # +--+ | | - # +--+ - # - if ts.start < c.start and ts.end > c.end: - solution_choices.add("theirs-end") - solution_choices.add("theirs-start") - solution_choices.add("theirs-both") - - # Fully overlapping: projected starts later and ends earlier than existing - # - # ex. pr. - # +--+ - # | | +--+ - # | | | | - # | | +--+ - # +--+ - # - if ts.start > c.start and ts.end < c.end: - solution_choices.add("ours-end") - solution_choices.add("ours-start") - solution_choices.add("ours-both") - - if len(collisions) > 0: - solutions[ts.hash] = "" - - projected_entry["collisions"] = collisions - projected_entry["solution_choices"] = solution_choices - projected_entry["error"] = None - projected.append(projected_entry) - - conflicts["projected"] = projected - conflicts["solutions"] = solutions - conflicts["notes"] = {} - conflicts["playlists"] = {} - - return conflicts - - # FIXME: this does not belong here - @staticmethod - def make_conflicts(sdl, schedule_pk, show_pk): - """ - Retrieves POST vars - Generates a schedule - Generates conflicts: Returns timeslots, collisions, solutions as JSON - Returns conflicts dict - """ - - # Generate schedule to be saved - schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) - - # Copy if first_date changes for generating timeslots - gen_schedule = schedule - - # Generate timeslots - - # If extending: Get last timeslot and start generating from that date on - if schedule_pk is not None: - existing_schedule = Schedule.objects.get(pk=int(schedule_pk)) - - if schedule.last_date > existing_schedule.last_date: - last_timeslot = ( - TimeSlot.objects.filter(schedule=existing_schedule) - .order_by("start") - .reverse()[0] - ) - gen_schedule.first_date = last_timeslot.start.date() + timedelta(days=1) - - timeslots = Schedule.generate_timeslots(gen_schedule) - - # Generate conflicts and add schedule - conflicts = Schedule.generate_conflicts(timeslots) - conflicts["schedule"] = model_to_dict(schedule) - - return conflicts - - # FIXME: this does not belong here - @staticmethod - def resolve_conflicts(data, schedule_pk, show_pk): - """ - Resolves conflicts - Expects JSON POST/PUT data from /shows/1/schedules/ - - Returns a list of dicts if errors were found - Returns an empty list if resolution was successful - """ - - sdl = data["schedule"] - solutions = data.get("solutions", []) - - # Regenerate conflicts - schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) - show = schedule.show - conflicts = Schedule.make_conflicts(sdl, schedule_pk, show_pk) - - if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date: - raise ValidationError( - _("Start and end dates must not be the same."), - code="no-same-day-start-and-end", - ) - - if schedule.last_date < schedule.first_date: - raise ValidationError( - _("End date mustn't be before start."), - code="no-start-after-end", - ) - - num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0]) - - if len(solutions) != num_conflicts: - raise ScheduleConflictError( - _("Numbers of conflicts and solutions don't match."), - code="one-solution-per-conflict", - conflicts=conflicts, - ) - - # Projected timeslots to create - create = [] - - # Existing timeslots to update - update = [] - - # Existing timeslots to delete - delete = [] - - # Error messages - errors = {} - - for ts in conflicts["projected"]: - # If no solution necessary - # - # - Create the projected timeslot and skip - # - if "solution_choices" not in ts or len(ts["collisions"]) < 1: - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - continue - - # Check hash (if start, end, rrule or byweekday changed) - if not ts["hash"] in solutions: - errors[ts["hash"]] = _("This change on the timeslot is not allowed.") - continue - - # If no resolution given - # - # - Skip - # - if solutions[ts["hash"]] == "": - errors[ts["hash"]] = _("No solution given.") - continue - - # If resolution is not accepted for this conflict - # - # - Skip - # - if not solutions[ts["hash"]] in ts["solution_choices"]: - errors[ts["hash"]] = _("Given solution is not accepted for this conflict.") - continue - - """Conflict resolution""" - - existing = ts["collisions"][0] - solution = solutions[ts["hash"]] - - # theirs - # - # - Discard the projected timeslot - # - Keep the existing collision(s) - # - if solution == "theirs": - continue - - # ours - # - # - Create the projected timeslot - # - Delete the existing collision(s) - # - if solution == "ours": - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - - # Delete collision(s) - for ex in ts["collisions"]: - try: - existing_ts = TimeSlot.objects.get(pk=ex["id"]) - delete.append(existing_ts) - except ObjectDoesNotExist: - pass - - # theirs-end - # - # - Keep the existing timeslot - # - Create projected with end of existing start - # - if solution == "theirs-end": - projected_ts = TimeSlot.objects.instantiate( - ts["start"], existing["start"], schedule, show - ) - create.append(projected_ts) - - # ours-end - # - # - Create the projected timeslot - # - Change the start of the existing collision to projected end - # - if solution == "ours-end": - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - - existing_ts = TimeSlot.objects.get(pk=existing["id"]) - existing_ts.start = parse_datetime(ts["end"]) - update.append(existing_ts) - - # theirs-start - # - # - Keep existing - # - Create projected with start time of existing end - # - if solution == "theirs-start": - projected_ts = TimeSlot.objects.instantiate( - existing["end"], ts["end"], schedule, show - ) - create.append(projected_ts) - - # ours-start - # - # - Create the projected timeslot - # - Change end of existing to projected start - # - if solution == "ours-start": - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - - existing_ts = TimeSlot.objects.get(pk=existing["id"]) - existing_ts.end = parse_datetime(ts["start"]) - update.append(existing_ts) - - # theirs-both - # - # - Keep existing - # - Create two projected timeslots with end of existing start and start of existing - # end - # - if solution == "theirs-both": - projected_ts = TimeSlot.objects.instantiate( - ts["start"], existing["start"], schedule, show - ) - create.append(projected_ts) - - projected_ts = TimeSlot.objects.instantiate( - existing["end"], ts["end"], schedule, show - ) - create.append(projected_ts) - - # ours-both - # - # - Create projected - # - Split existing into two: - # - Set existing end time to projected start - # - Create another one with start = projected end and end = existing end - # - if solution == "ours-both": - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - - existing_ts = TimeSlot.objects.get(pk=existing["id"]) - existing_ts.end = parse_datetime(ts["start"]) - update.append(existing_ts) - - projected_ts = TimeSlot.objects.instantiate( - ts["end"], existing["end"], schedule, show - ) - create.append(projected_ts) - - # If there were any errors, don't make any db changes yet - # but add error messages and return already chosen solutions - if len(errors) > 0: - conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk) - - partly_resolved = conflicts["projected"] - saved_solutions = {} - - # Add already chosen resolutions and error message to conflict - for index, c in enumerate(conflicts["projected"]): - # The element should only exist if there was a collision - if len(c["collisions"]) > 0: - saved_solutions[c["hash"]] = "" - - if c["hash"] in solutions and solutions[c["hash"]] in c["solution_choices"]: - saved_solutions[c["hash"]] = solutions[c["hash"]] - - if c["hash"] in errors: - partly_resolved[index]["error"] = errors[c["hash"]] - - # Re-insert post data - conflicts["projected"] = partly_resolved - conflicts["solutions"] = saved_solutions - conflicts["notes"] = data.get("notes") - conflicts["playlists"] = data.get("playlists") - - raise ScheduleConflictError( - _("Not all conflicts have been resolved."), - code="unresolved-conflicts", - conflicts=conflicts, - ) - - # Collect upcoming timeslots to delete which might still remain - del_timeslots = TimeSlot.objects.filter( - schedule=schedule, - start__gt=timezone.make_aware(datetime.combine(schedule.last_date, time(0, 0))), - ) - for del_ts in del_timeslots: - delete.append(del_ts) - - # If 'dryrun' is true, just return the projected changes instead of executing them - if "dryrun" in sdl and sdl["dryrun"]: - return { - "create": [model_to_dict(ts) for ts in create], - "update": [model_to_dict(ts) for ts in update], - "delete": [model_to_dict(ts) for ts in delete], - } - - """Database changes if no errors found""" - - # Only save schedule if timeslots were created - if create: - # Create or update schedule - schedule.save() - - # Update timeslots - for ts in update: - ts.save(update_fields=["start", "end"]) - - # Create timeslots - for ts in create: - ts.schedule = schedule - - # Reassign playlists - if "playlists" in data and ts.hash in data["playlists"]: - ts.playlist_id = int(data["playlists"][ts.hash]) - - ts.save() - - # Reassign notes - if "notes" in data and ts.hash in data["notes"]: - try: - note = Note.objects.get(pk=int(data["notes"][ts.hash])) - note.timeslot_id = ts.id - note.save(update_fields=["timeslot_id"]) - - timeslot = TimeSlot.objects.get(pk=ts.id) - timeslot.note_id = note.id - timeslot.save(update_fields=["note_id"]) - except ObjectDoesNotExist: - pass - - # Delete manually resolved timeslots and those after until - for dl in delete: - dl.delete() - - return model_to_dict(schedule) - class TimeSlotManager(models.Manager): @staticmethod @@ -1029,7 +374,6 @@ class TimeSlotManager(models.Manager): start=parse_datetime(start), end=parse_datetime(end), show=show, - is_repetition=schedule.is_repetition, schedule=schedule, ) @@ -1057,16 +401,22 @@ class TimeSlotManager(models.Manager): class TimeSlot(models.Model): + end = models.DateTimeField() + memo = models.TextField(blank=True) + note_id = models.IntegerField(null=True, editable=False) + playlist_id = models.IntegerField(null=True) + repetition_of = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="repetitions", + ) schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name="timeslots") show = models.ForeignKey( Show, editable=False, on_delete=models.CASCADE, related_name="timeslots" ) start = models.DateTimeField() - end = models.DateTimeField() - memo = models.TextField(blank=True) - is_repetition = models.BooleanField(default=False) - playlist_id = models.IntegerField(null=True) - note_id = models.IntegerField(null=True, editable=False) objects = TimeSlotManager() @@ -1104,30 +454,27 @@ class TimeSlot(models.Model): class Note(models.Model): - timeslot = models.OneToOneField(TimeSlot, on_delete=models.CASCADE, unique=True) - title = models.CharField(max_length=128) - slug = models.SlugField(max_length=32, unique=True) - summary = models.TextField(blank=True) - content = models.TextField() - ppoi = PPOIField() - height = models.PositiveIntegerField(blank=True, null=True, editable=False) - width = models.PositiveIntegerField(blank=True, null=True, editable=False) - image = VersatileImageField( - blank=True, - null=True, - upload_to="note_images", - width_field="width", - height_field="height", - ppoi_field="ppoi", - ) - status = models.IntegerField(default=1) - start = models.DateTimeField(editable=False) - show = models.ForeignKey(Show, on_delete=models.CASCADE, related_name="notes", editable=True) cba_id = models.IntegerField(blank=True, null=True) - user = models.ForeignKey( - User, editable=False, on_delete=models.CASCADE, related_name="users", default=1 + content = models.TextField() + contributors = models.ManyToManyField(Host, related_name="contributions") + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=150) + image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="notes") + owner = models.ForeignKey( + User, + editable=False, + on_delete=models.CASCADE, + related_name="notes", + default=1, ) - host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="hosts", null=True) + playlist = models.TextField(blank=True, null=True) + slug = models.SlugField(max_length=32, unique=True) + summary = models.TextField(blank=True) + tags = models.TextField(blank=True, null=True) + timeslot = models.OneToOneField(TimeSlot, on_delete=models.CASCADE, unique=True) + title = models.CharField(max_length=128) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + updated_by = models.CharField(blank=True, max_length=150, null=True) class Meta: ordering = ("timeslot",) @@ -1136,20 +483,30 @@ class Note(models.Model): return self.title def save(self, *args, **kwargs): - self.start = self.timeslot.start - self.show = self.timeslot.schedule.show - timeslot = TimeSlot.objects.get(pk=self.timeslot.id) timeslot.note_id = self.id timeslot.save() super(Note, self).save(*args, **kwargs) - # Generate thumbnails - if self.image.name and THUMBNAIL_SIZES: - for size in THUMBNAIL_SIZES: - self.image.thumbnail = self.image.crop[size].name - class NoteLink(Link): note = models.ForeignKey(Note, on_delete=models.CASCADE, related_name="links") + + +class UserProfile(models.Model): + cba_username = models.CharField("CBA Username", blank=True, max_length=60) + cba_user_token = models.CharField("CBA Token", blank=True, max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=150) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + updated_by = models.CharField(blank=True, max_length=150, null=True) + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="profile", + editable=False, + ) + + def __str__(self): + return self.user.username diff --git a/program/serializers.py b/program/serializers.py index 260cc5161e4a4754cdfbbb73c55953f5e44857b2..9336939785ddcb67ada771c3f9a93da710f666c0 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -18,9 +18,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # -from profile.models import Profile -from profile.serializers import ProfileSerializer -from typing import List +import re +from typing import List, TypedDict from rest_framework import serializers @@ -32,18 +31,23 @@ from program.models import ( FundingCategory, Host, HostLink, + Image, Language, + LicenseType, + LinkType, MusicFocus, Note, NoteLink, + RRule, Schedule, Show, ShowLink, TimeSlot, Topic, Type, + UserProfile, ) -from program.utils import get_audio_url +from program.utils import delete_links, get_audio_url from steering.settings import THUMBNAIL_SIZES SOLUTION_CHOICES = { @@ -78,6 +82,26 @@ class ErrorSerializer(serializers.Serializer): code = serializers.CharField(allow_null=True) +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ( + "user", + "cba_username", + "cba_user_token", + "created_at", + "created_by", + "updated_at", + "updated_by", + ) + read_only_fields = ( + "created_at", + "created_by", + "updated_at", + "updated_by", + ) + + class UserSerializer(serializers.ModelSerializer): # Add profile fields to JSON profile = ProfileSerializer(required=False) @@ -85,16 +109,16 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ( - "id", - "username", - "first_name", - "last_name", "email", - "is_staff", + "first_name", + "id", "is_active", + "is_staff", "is_superuser", + "last_name", "password", "profile", + "username", ) def create(self, validated_data): @@ -102,9 +126,7 @@ class UserSerializer(serializers.ModelSerializer): Create and return a new User instance, given the validated data. """ - profile_data = ( - validated_data.pop("profile") if "profile" in validated_data else None - ) + profile_data = validated_data.pop("profile") if "profile" in validated_data else None user = super(UserSerializer, self).create(validated_data) user.date_joined = timezone.now() @@ -112,10 +134,11 @@ class UserSerializer(serializers.ModelSerializer): user.save() if profile_data: - profile = Profile( + profile = UserProfile( user=user, cba_username=profile_data.get("cba_username").strip(), cba_user_token=profile_data.get("cba_user_token").strip(), + created_by=self.context["user"], ) profile.save() @@ -131,23 +154,20 @@ class UserSerializer(serializers.ModelSerializer): instance.email = validated_data.get("email", instance.email) instance.is_active = validated_data.get("is_active", instance.is_active) instance.is_staff = validated_data.get("is_staff", instance.is_staff) - instance.is_superuser = validated_data.get( - "is_superuser", instance.is_superuser - ) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) - profile_data = ( - validated_data.pop("profile") if "profile" in validated_data else None - ) + profile_data = validated_data.pop("profile") if "profile" in validated_data else None if profile_data: # TODO: How to hook into this from ProfileSerializer without having to call it here? try: - profile = Profile.objects.get(user=instance.id) + profile = UserProfile.objects.get(user=instance.id) except ObjectDoesNotExist: - profile = Profile.objects.create(user=instance, **profile_data) + profile = UserProfile.objects.create(user=instance, **profile_data) profile.cba_username = profile_data.get("cba_username") profile.cba_user_token = profile_data.get("cba_user_token") + profile.updated_by = self.context["user"] profile.save() instance.save() @@ -155,47 +175,149 @@ class UserSerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer): - # TODO: remove this when the dashboard is updated - category = serializers.CharField(source="name") - class Meta: model = Category - # TODO: replace `category` with `name` when the dashboard is updated - fields = ("id", "category", "abbrev", "slug", "is_active", "description") + fields = ("description", "id", "is_active", "name", "slug", "subtitle") + + +class LinkTypeSerializer(serializers.ModelSerializer): + class Meta: + model = LinkType + fields = ("name", "type") + + +class LicenseTypeSerializer(serializers.ModelSerializer): + class Meta: + model = LicenseType + fields = ("name", "type") class HostLinkSerializer(serializers.ModelSerializer): class Meta: model = HostLink - fields = ("description", "url") + fields = ("type", "url") -class HostSerializer(serializers.ModelSerializer): - links = HostLinkSerializer(many=True, required=False) +class PPOIField(serializers.CharField): + def validate_format(self, value: str): + if not re.match(r"\d(?:\.\d+)?x\d(?:\.\d+)?", value): + raise serializers.ValidationError("PPOI must match format: ${float}x${float}") + + def __init__(self, **kwargs): + kwargs["max_length"] = 20 + kwargs.setdefault("validators", []) + kwargs["validators"].append(self.validate_format) + super().__init__(**kwargs) + + def to_representation(self, value: tuple[float, float]): + [left, top] = value + return f"{left}x{top}" + + +class Thumbnail(TypedDict): + width: float + height: float + url: str + + +class ImageSerializer(serializers.ModelSerializer): + ppoi = PPOIField() thumbnails = serializers.SerializerMethodField() @staticmethod - def get_thumbnails(host) -> List[str]: + def get_thumbnails(instance) -> List[Thumbnail]: """Returns thumbnails""" thumbnails = [] - if host.image.name and THUMBNAIL_SIZES: + if instance.image.name and THUMBNAIL_SIZES: for size in THUMBNAIL_SIZES: - thumbnails.append(host.image.crop[size].name) + [width, height] = size.split("x") + thumbnails.append( + { + "width": int(width), + "height": int(height), + "url": instance.image.crop[size].url, + } + ) return thumbnails + class Meta: + model = Image + read_only_fields = ( + "height", + "id", + "thumbnails", + "width", + ) + fields = ( + "alt_text", + "credits", + "image", + "ppoi", + ) + read_only_fields + + def create(self, validated_data): + """Create and return a new Image instance, given the validated data.""" + + image = Image.objects.create(**validated_data | self.context) + image.save() + + return image + + def update(self, instance, validated_data): + """Update and return an existing Image instance, given the validated data.""" + + # Only these fields can be updated. + instance.alt_text = validated_data.get("alt_text", instance.alt_text) + instance.credits = validated_data.get("credits", instance.credits) + instance.image.ppoi = validated_data.get("ppoi", instance.ppoi) + + instance.save() + + return instance + + +class HostSerializer(serializers.ModelSerializer): + image = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, + ) + links = HostLinkSerializer(many=True, required=False) + class Meta: model = Host - fields = "__all__" + read_only_fields = ( + "created_at", + "created_by", + "updated_at", + "updated_by", + ) + fields = ( + "biography", + "email", + "id", + "image", + "is_active", + "links", + "name", + ) + read_only_fields def create(self, validated_data): + """ + Create and return a new Host instance, given the validated data. + """ + links_data = validated_data.pop("links", []) - host = Host.objects.create(**validated_data) + + host = Host.objects.create(**validated_data | self.context) # created_by for link_data in links_data: HostLink.objects.create(host=host, **link_data) + host.save() + return host def update(self, instance, validated_data): @@ -203,22 +325,20 @@ class HostSerializer(serializers.ModelSerializer): Update and return an existing Host instance, given the validated data. """ - instance.name = validated_data.get("name", instance.name) - instance.is_active = validated_data.get("is_active", instance.is_active) - instance.email = validated_data.get("email", instance.email) - instance.website = validated_data.get("website", instance.website) instance.biography = validated_data.get("biography", instance.biography) + instance.email = validated_data.get("email", instance.email) instance.image = validated_data.get("image", instance.image) - instance.ppoi = validated_data.get("ppoi", instance.ppoi) - - if instance.links.count() > 0: - for link in instance.links.all(): - link.delete(keep_parents=True) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.name = validated_data.get("name", instance.name) if links_data := validated_data.get("links"): + instance = delete_links(instance) + for link_data in links_data: HostLink.objects.create(host=instance, **link_data) + instance.updated_by = self.context.get("updated_by") + instance.save() return instance @@ -227,122 +347,96 @@ class HostSerializer(serializers.ModelSerializer): class LanguageSerializer(serializers.ModelSerializer): class Meta: model = Language - fields = ("id", "name", "is_active") + fields = ("id", "is_active", "name") class TopicSerializer(serializers.ModelSerializer): - # TODO: remove this when the dashboard is updated - topic = serializers.CharField(source="name") - class Meta: model = Topic - # TODO: replace `topic` with `name` when the dashboard is updated - fields = ("id", "topic", "abbrev", "slug", "is_active") + fields = ("id", "is_active", "name", "slug") class MusicFocusSerializer(serializers.ModelSerializer): - # TODO: remove this when the dashboard is updated - focus = serializers.CharField(source="name") - class Meta: model = MusicFocus - # TODO: replace `focus` with `name` when the dashboard is updated - fields = ("id", "focus", "abbrev", "slug", "is_active") + fields = ("id", "is_active", "name", "slug") class TypeSerializer(serializers.ModelSerializer): - # TODO: remove this when the dashboard is updated - type = serializers.CharField(source="name") - class Meta: model = Type - # TODO: replace `type` with `name` when the dashboard is updated - fields = ("id", "type", "slug", "is_active") + fields = ("id", "is_active", "name", "slug") class FundingCategorySerializer(serializers.ModelSerializer): - # TODO: remove this when the dashboard is updated - fundingcategory = serializers.CharField(source="name") - class Meta: model = FundingCategory - # TODO: replace `fundingcategory` with `name` when the dashboard is updated - fields = ("id", "fundingcategory", "abbrev", "slug", "is_active") + fields = ("id", "is_active", "name", "slug") class ShowLinkSerializer(serializers.ModelSerializer): class Meta: model = ShowLink - fields = ("description", "url") + fields = ("type", "url") class ShowSerializer(serializers.HyperlinkedModelSerializer): - links = HostLinkSerializer(many=True, required=False) - owners = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), many=True) - category = serializers.PrimaryKeyRelatedField( - queryset=Category.objects.all(), many=True - ) + category = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True) + funding_category = serializers.PrimaryKeyRelatedField(queryset=FundingCategory.objects.all()) hosts = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True) - language = serializers.PrimaryKeyRelatedField( - queryset=Language.objects.all(), many=True - ) - topic = serializers.PrimaryKeyRelatedField(queryset=Topic.objects.all(), many=True) - # TODO: replace `musicfocs` with `music_focus` and remove the source when the dashboard is - # updated - musicfocus = serializers.PrimaryKeyRelatedField( - queryset=MusicFocus.objects.all(), source="music_focus", many=True + image = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, ) - type = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all()) - # TODO: replace `fundingcategory` with `funding_category` and remove the source when the - # dashboard is updated - fundingcategory = serializers.PrimaryKeyRelatedField( - queryset=FundingCategory.objects.all(), source="funding_category" + language = serializers.PrimaryKeyRelatedField(queryset=Language.objects.all(), many=True) + links = HostLinkSerializer(many=True, required=False) + logo = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, ) + music_focus = serializers.PrimaryKeyRelatedField(queryset=MusicFocus.objects.all(), many=True) + owners = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), many=True) predecessor = serializers.PrimaryKeyRelatedField( queryset=Show.objects.all(), required=False, allow_null=True ) - thumbnails = serializers.SerializerMethodField() - - @staticmethod - def get_thumbnails(show) -> List[str]: - """Returns thumbnails""" - thumbnails = [] - - if show.image.name and THUMBNAIL_SIZES: - for size in THUMBNAIL_SIZES: - thumbnails.append(show.image.crop[size].name) - - return thumbnails + topic = serializers.PrimaryKeyRelatedField(queryset=Topic.objects.all(), many=True) + type = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all()) class Meta: model = Show + read_only_fields = ( + "created_at", + "created_by", + "updated_at", + "updated_by", + ) fields = ( - "id", - "name", - "slug", - "image", - "ppoi", - "logo", - "short_description", - "description", - "email", - "website", - "type", - "fundingcategory", - "predecessor", + "category", "cba_series_id", "default_playlist_id", - "category", + "description", + "email", + "funding_category", "hosts", - "owners", - "language", - "topic", - "musicfocus", - "thumbnails", + "id", + "image", + "internal_note", "is_active", "is_public", - "links" - ) + "language", + "links", + "logo", + "music_focus", + "name", + "owners", + "predecessor", + "short_description", + "slug", + "topic", + "type", + ) + read_only_fields def create(self, validated_data): """ @@ -357,7 +451,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): music_focus = validated_data.pop("music_focus") links_data = validated_data.pop("links", []) - show = Show.objects.create(**validated_data) + show = Show.objects.create(**validated_data | self.context) # created_by # Save many-to-many relationships show.owners.set(owners) @@ -371,6 +465,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): ShowLink.objects.create(show=show, **link_data) show.save() + return show def update(self, instance, validated_data): @@ -378,57 +473,75 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): Update and return an existing Show instance, given the validated data. """ - instance.name = validated_data.get("name", instance.name) - instance.slug = validated_data.get("slug", instance.slug) - instance.image = validated_data.get("image", instance.image) - instance.ppoi = validated_data.get("ppoi", instance.ppoi) - instance.logo = validated_data.get("logo", instance.logo) - instance.short_description = validated_data.get( - "short_description", instance.short_description - ) - instance.description = validated_data.get("description", instance.description) - instance.email = validated_data.get("email", instance.email) - instance.website = validated_data.get("website", instance.website) - instance.cba_series_id = validated_data.get( - "cba_series_id", instance.cba_series_id - ) + instance.cba_series_id = validated_data.get("cba_series_id", instance.cba_series_id) instance.default_playlist_id = validated_data.get( "default_playlist_id", instance.default_playlist_id ) - instance.type = validated_data.get("type", instance.type) + instance.description = validated_data.get("description", instance.description) + instance.email = validated_data.get("email", instance.email) instance.funding_category = validated_data.get( "funding_category", instance.funding_category ) - instance.predecessor = validated_data.get("predecessor", instance.predecessor) + instance.image = validated_data.get("image", instance.image) + instance.internal_note = validated_data.get("internal_note", instance.internal_note) instance.is_active = validated_data.get("is_active", instance.is_active) instance.is_public = validated_data.get("is_public", instance.is_public) + instance.logo = validated_data.get("logo", instance.logo) + instance.name = validated_data.get("name", instance.name) + instance.predecessor = validated_data.get("predecessor", instance.predecessor) + instance.short_description = validated_data.get( + "short_description", instance.short_description + ) + instance.slug = validated_data.get("slug", instance.slug) + instance.type = validated_data.get("type", instance.type) - instance.owners.set(validated_data.get("owners", instance.owners)) instance.category.set(validated_data.get("category", instance.category)) instance.hosts.set(validated_data.get("hosts", instance.hosts)) instance.language.set(validated_data.get("language", instance.language)) + instance.music_focus.set(validated_data.get("music_focus", instance.music_focus)) + instance.owners.set(validated_data.get("owners", instance.owners)) instance.topic.set(validated_data.get("topic", instance.topic)) - instance.music_focus.set( - validated_data.get("music_focus", instance.music_focus) - ) - - if instance.links.count() > 0: - for link in instance.links.all(): - link.delete(keep_parents=True) if links_data := validated_data.get("links"): + instance = delete_links(instance) + for link_data in links_data: ShowLink.objects.create(show=instance, **link_data) + instance.updated_by = self.context.get("updated_by") + instance.save() return instance +class RRuleSerializer(serializers.ModelSerializer): + class Meta: + model = RRule + fields = ( + "id", + "name", + ) + read_only_fields = fields + + class ScheduleSerializer(serializers.ModelSerializer): class Meta: model = Schedule - fields = "__all__" + fields = ( + "add_business_days_only", + "add_days_no", + "by_weekday", + "default_playlist_id", + "end_time", + "first_date", + "id", + "is_repetition", + "last_date", + "rrule", + "show", + "start_time", + ) class UnsavedScheduleSerializer(ScheduleSerializer): @@ -448,7 +561,20 @@ class ScheduleInRequestSerializer(ScheduleSerializer): class Meta: model = Schedule - fields = "__all__" + fields = ( + "add_business_days_only", + "add_days_no", + "by_weekday", + "default_playlist_id", + "dryrun", + "end_time", + "first_date", + "is_repetition", + "last_date", + "rrule", + "show", + "start_time", + ) def create(self, validated_data): """Create and return a new Schedule instance, given the validated data.""" @@ -471,9 +597,7 @@ class ScheduleInRequestSerializer(ScheduleSerializer): instance.start_time = validated_data.get("start_time", instance.start_time) instance.end_time = validated_data.get("end_time", instance.end_time) instance.last_date = validated_data.get("last_date", instance.last_date) - instance.is_repetition = validated_data.get( - "is_repetition", instance.is_repetition - ) + instance.is_repetition = validated_data.get("is_repetition", instance.is_repetition) instance.default_playlist_id = validated_data.get( "default_playlist_id", instance.default_playlist_id ) @@ -495,7 +619,7 @@ class CollisionSerializer(serializers.Serializer): playlist_id = serializers.IntegerField(allow_null=True) show = serializers.IntegerField() show_name = serializers.CharField() - is_repetition = serializers.BooleanField() + repetition_of = serializers.IntegerField(allow_null=True) schedule = serializers.IntegerField() memo = serializers.CharField() note_id = serializers.IntegerField(allow_null=True) @@ -507,22 +631,16 @@ class ProjectedTimeSlotSerializer(serializers.Serializer): end = serializers.DateTimeField() collisions = CollisionSerializer(many=True) error = serializers.CharField(allow_null=True) - solution_choices = serializers.ListField( - child=serializers.ChoiceField(SOLUTION_CHOICES) - ) + solution_choices = serializers.ListField(child=serializers.ChoiceField(SOLUTION_CHOICES)) class DryRunTimeSlotSerializer(serializers.Serializer): - id = serializers.PrimaryKeyRelatedField( - queryset=TimeSlot.objects.all(), allow_null=True - ) - schedule = serializers.PrimaryKeyRelatedField( - queryset=Schedule.objects.all(), allow_null=True - ) + id = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), allow_null=True) + schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all(), allow_null=True) playlist_id = serializers.IntegerField(allow_null=True) start = serializers.DateField() end = serializers.DateField() - is_repetition = serializers.BooleanField() + repetition_of = serializers.IntegerField(allow_null=True) memo = serializers.CharField() @@ -556,10 +674,23 @@ class ScheduleDryRunResponseSerializer(serializers.Serializer): class TimeSlotSerializer(serializers.ModelSerializer): show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all()) schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all()) + repetition_of = serializers.PrimaryKeyRelatedField( + queryset=TimeSlot.objects.all(), allow_null=True + ) class Meta: model = TimeSlot - fields = "__all__" + fields = ( + "id", + "end", + "memo", + "note_id", + "playlist_id", + "repetition_of", + "schedule", + "show", + "start", + ) def create(self, validated_data): """Create and return a new TimeSlot instance, given the validated data.""" @@ -570,9 +701,7 @@ class TimeSlotSerializer(serializers.ModelSerializer): # Only save certain fields instance.memo = validated_data.get("memo", instance.memo) - instance.is_repetition = validated_data.get( - "is_repetition", instance.is_repetition - ) + instance.repetition_of = validated_data.get("repetition_of", instance.repetition_of) instance.playlist_id = validated_data.get("playlist_id", instance.playlist_id) instance.save() return instance @@ -581,43 +710,59 @@ class TimeSlotSerializer(serializers.ModelSerializer): class NoteLinkSerializer(serializers.ModelSerializer): class Meta: model = NoteLink - fields = ("description", "url") + fields = ("type", "url") class NoteSerializer(serializers.ModelSerializer): + contributors = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True) + image = serializers.PrimaryKeyRelatedField( + queryset=Image.objects.all(), required=False, allow_null=True + ) links = NoteLinkSerializer(many=True, required=False) - show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all()) - timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all()) - host = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all()) - thumbnails = serializers.SerializerMethodField() - - @staticmethod - def get_thumbnails(note) -> List[str]: - """Returns thumbnails""" - thumbnails = [] - - if note.image.name and THUMBNAIL_SIZES: - for size in THUMBNAIL_SIZES: - thumbnails.append(note.image.crop[size].name) - - return thumbnails + timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), required=False) class Meta: model = Note - fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "created_by", + "updated_at", + "updated_by", + ) + fields = ( + "cba_id", + "content", + "contributors", + "id", + "image", + "links", + "owner", + "playlist", + "slug", + "summary", + "tags", + "timeslot", + "title", + ) + read_only_fields def create(self, validated_data): """Create and return a new Note instance, given the validated data.""" + links_data = validated_data.pop("links", []) + contributors = validated_data.pop("contributors", []) - # Save the creator - validated_data["user_id"] = self.context["user_id"] + # the creator of the note is the owner + validated_data["owner"] = self.context["request"].user + note = Note.objects.create( + created_by=self.context["request"].user.username, **validated_data + ) - note = Note.objects.create(**validated_data) + note.contributors.set(contributors) if cba_id := validated_data.get("cba_id"): if audio_url := get_audio_url(cba_id): - NoteLink.objects.create(note=note, description="CBA", url=audio_url) + NoteLink.objects.create(note=note, type="CBA", url=audio_url) for link_data in links_data: NoteLink.objects.create(note=note, **link_data) @@ -638,30 +783,28 @@ class NoteSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """Update and return an existing Note instance, given the validated data.""" - instance.show = validated_data.get("show", instance.show) - instance.timeslot = validated_data.get("timeslot", instance.timeslot) - instance.title = validated_data.get("title", instance.title) - instance.slug = validated_data.get("slug", instance.slug) - instance.summary = validated_data.get("summary", instance.summary) + instance.cba_id = validated_data.get("cba_id", instance.cba_id) instance.content = validated_data.get("content", instance.content) instance.image = validated_data.get("image", instance.image) - instance.ppoi = validated_data.get("ppoi", instance.ppoi) - instance.status = validated_data.get("status", instance.status) - instance.host = validated_data.get("host", instance.host) - instance.cba_id = validated_data.get("cba_id", instance.cba_id) + instance.slug = validated_data.get("slug", instance.slug) + instance.summary = validated_data.get("summary", instance.summary) + instance.timeslot = validated_data.get("timeslot", instance.timeslot) + instance.title = validated_data.get("title", instance.title) - if instance.links.count() > 0: - for link in instance.links.all(): - link.delete(keep_parents=True) + instance.contributors.set(validated_data.get("contributors", instance.contributors)) if cba_id := validated_data.get("cba_id"): if audio_url := get_audio_url(cba_id): - NoteLink.objects.create(note=instance, description="CBA", url=audio_url) + NoteLink.objects.create(note=instance, type="CBA", url=audio_url) if links_data := validated_data.get("links"): + instance = delete_links(instance) + for link_data in links_data: NoteLink.objects.create(note=instance, **link_data) + instance.updated_by = self.context.get("request").user.username + instance.save() # Remove existing note connections from timeslots diff --git a/program/services.py b/program/services.py new file mode 100644 index 0000000000000000000000000000000000000000..fc323d4120aa6ad729d280cd760270d6b708cff8 --- /dev/null +++ b/program/services.py @@ -0,0 +1,707 @@ +# +# steering, Programme/schedule management for AURA +# +# Copyright (C) 2017, Ingo Leindecker +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from datetime import datetime, time, timedelta +from typing import TypedDict + +from dateutil.relativedelta import relativedelta +from dateutil.rrule import rrule +from rest_framework.exceptions import ValidationError + +from django.core.exceptions import ObjectDoesNotExist +from django.forms.models import model_to_dict +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from program.models import Note, RRule, Schedule, ScheduleConflictError, Show, TimeSlot +from program.utils import parse_date, parse_datetime, parse_time +from steering.settings import ( + AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE, + AUTO_SET_LAST_DATE_TO_END_OF_YEAR, +) + + +class ScheduleData(TypedDict): + add_business_days_only: bool | None + add_days_no: int | None + by_weekday: int | None + default_playlist_id: int | None + dryrun: bool | None + end_time: str + first_date: str + id: int | None + is_repetition: bool + last_date: str | None + rrule: int + show: int | None + start_time: str + + +class Collision(TypedDict): + end: str + id: int + memo: str + note_id: int | None + playlist_id: int | None + schedule: int + show: int + show_name: str + start: str + + +class ProjectedEntry(TypedDict): + collisions: list[Collision] + end: str + error: str | None + hash: str + solution_choices: set[str] + start: str + + +class Conflicts(TypedDict): + notes: dict + playlists: dict + projected: list[ProjectedEntry] + solutions: dict[str, str] + + +class ScheduleCreateUpdateData(TypedDict): + notes: dict + playlists: dict + schedule: ScheduleData + solutions: dict[str, str] + + +def resolve_conflicts(data: ScheduleCreateUpdateData, schedule_pk: int | None, show_pk: int): + """ + Resolves conflicts + Expects JSON POST/PUT data from /shows/1/schedules/ + + Returns a list of dicts if errors were found + Returns an empty list if resolution was successful + """ + + schedule = data["schedule"] + solutions = data.get("solutions", []) # only needed if conflicts exist + + new_schedule = instantiate_upcoming_schedule(schedule, show_pk, schedule_pk) + show = new_schedule.show + conflicts = make_conflicts(schedule, schedule_pk, show_pk) + + if new_schedule.rrule.freq > 0 and new_schedule.first_date == new_schedule.last_date: + raise ValidationError( + _("Start and end dates must not be the same."), + code="no-same-day-start-and-end", + ) + + if new_schedule.last_date < new_schedule.first_date: + raise ValidationError( + _("End date mustn't be before start."), + code="no-start-after-end", + ) + + num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0]) + + if len(solutions) != num_conflicts: + raise ScheduleConflictError( + _("Numbers of conflicts and solutions don't match."), + code="one-solution-per-conflict", + conflicts=conflicts, + ) + + to_create: list[TimeSlot] = [] + to_update: list[TimeSlot] = [] + to_delete: list[TimeSlot] = [] + + errors = {} + + for timeslot in conflicts["projected"]: + # If no solution necessary: Create the projected timeslot and skip + if "solution_choices" not in timeslot or len(timeslot["collisions"]) == 0: + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], timeslot["end"], new_schedule, show + ), + ) + continue + + # Check hash (if start, end, rrule or by_weekday changed) + if not timeslot["hash"] in solutions: + errors[timeslot["hash"]] = _("This change on the timeslot is not allowed.") + continue + + # If no resolution given: skip + if solutions[timeslot["hash"]] == "": + errors[timeslot["hash"]] = _("No solution given.") + continue + + # If resolution is not accepted for this conflict: SKIP + if not solutions[timeslot["hash"]] in timeslot["solution_choices"]: + errors[timeslot["hash"]] = _("Given solution is not accepted for this conflict.") + continue + + """Conflict resolution""" + + existing = timeslot["collisions"][0] + solution = solutions[timeslot["hash"]] + + if solution == "theirs": + # - Discard the projected timeslot + # - Keep the existing collision(s) + continue + + if solution == "ours": + # - Create the projected timeslot + # - Delete the existing collision(s) + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], timeslot["end"], new_schedule, show + ), + ) + + # Delete collision(s) + for collision in timeslot["collisions"]: + try: + to_delete.append(TimeSlot.objects.get(pk=collision["id"])) + except ObjectDoesNotExist: + pass + + if solution == "theirs-end": + # - Keep the existing timeslot + # - Create projected with end of existing start + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], existing["start"], new_schedule, show + ), + ) + + if solution == "ours-end": + # - Create the projected timeslot + # - Change the start of the existing collision to projected end + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], timeslot["end"], new_schedule, show + ), + ) + + existing_ts = TimeSlot.objects.get(pk=existing["id"]) + existing_ts.start = parse_datetime(timeslot["end"]) + to_update.append(existing_ts) + + if solution == "theirs-start": + # - Keep existing + # - Create projected with start time of existing end + to_create.append( + TimeSlot.objects.instantiate(existing["end"], timeslot["end"], new_schedule, show), + ) + + if solution == "ours-start": + # - Create the projected timeslot + # - Change end of existing to projected start + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], timeslot["end"], new_schedule, show + ), + ) + + existing_ts = TimeSlot.objects.get(pk=existing["id"]) + existing_ts.end = parse_datetime(timeslot["start"]) + to_update.append(existing_ts) + + if solution == "theirs-both": + # - Keep existing + # - Create two projected timeslots with end of existing start and start of existing end + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], existing["start"], new_schedule, show + ), + ) + + to_create.append( + TimeSlot.objects.instantiate(existing["end"], timeslot["end"], new_schedule, show), + ) + + if solution == "ours-both": + # - Create projected + # - Split existing into two + # - Set existing end time to projected start + # - Create another one with start = projected end and end = existing end + to_create.append( + TimeSlot.objects.instantiate( + timeslot["start"], timeslot["end"], new_schedule, show + ), + ) + + existing_ts = TimeSlot.objects.get(pk=existing["id"]) + existing_ts.end = parse_datetime(timeslot["start"]) + to_update.append(existing_ts) + + to_create.append( + TimeSlot.objects.instantiate(timeslot["end"], existing["end"], new_schedule, show), + ) + + # If there were any errors, don't make any db changes yet + # but add error messages and return already chosen solutions + if len(errors) > 0: + conflicts = make_conflicts(model_to_dict(new_schedule), new_schedule.pk, show.pk) + + partly_resolved = conflicts["projected"] + saved_solutions = {} + + # Add already chosen resolutions and error message to conflict + for index, projected_entry in enumerate(conflicts["projected"]): + # The element should only exist if there was a collision + if len(projected_entry["collisions"]) > 0: + saved_solutions[projected_entry["hash"]] = "" + + if ( + projected_entry["hash"] in solutions + and solutions[projected_entry["hash"]] in projected_entry["solution_choices"] + ): + saved_solutions[projected_entry["hash"]] = solutions[projected_entry["hash"]] + + if projected_entry["hash"] in errors: + partly_resolved[index]["error"] = errors[projected_entry["hash"]] + + # Re-insert post data + conflicts["projected"] = partly_resolved + conflicts["solutions"] = saved_solutions + conflicts["notes"] = data.get("notes") + conflicts["playlists"] = data.get("playlists") + + raise ScheduleConflictError( + _("Not all conflicts have been resolved."), + code="unresolved-conflicts", + conflicts=conflicts, + ) + + remaining_timeslots = TimeSlot.objects.filter( + schedule=new_schedule, + start__gt=timezone.make_aware(datetime.combine(new_schedule.last_date, time(0, 0))), + ) + for timeslot in remaining_timeslots: + to_delete.append(timeslot) + + # If 'dryrun' is true, just return the projected changes instead of executing them + if "dryrun" in schedule and schedule["dryrun"]: + return { + "create": [model_to_dict(timeslot) for timeslot in to_create], + "update": [model_to_dict(timeslot) for timeslot in to_update], + "delete": [model_to_dict(timeslot) for timeslot in to_delete], + } + + # Database changes if no errors found + + if to_create: + new_schedule.save() + + for timeslot in to_update: + timeslot.save(update_fields=["start", "end"]) + + for timeslot in to_create: + timeslot.schedule = new_schedule + + # Reassign playlists + if "playlists" in data and timeslot.hash in data["playlists"]: + timeslot.playlist_id = int(data["playlists"][timeslot.hash]) + + timeslot.save() + + # Reassign notes + if "notes" in data and timeslot.hash in data["notes"]: + try: + note = Note.objects.get(pk=int(data["notes"][timeslot.hash])) + note.timeslot_id = timeslot.id + note.save(update_fields=["timeslot_id"]) + + timeslot = TimeSlot.objects.get(pk=timeslot.id) + timeslot.note_id = note.id + timeslot.save(update_fields=["note_id"]) + except ObjectDoesNotExist: + pass + + for timeslot in to_delete: + timeslot.delete() + + return model_to_dict(new_schedule) + + +def instantiate_upcoming_schedule( + data: ScheduleData, show_pk: int, pk: int | None = None +) -> Schedule: + """Returns an upcoming schedule instance for conflict resolution""" + + rrule = RRule.objects.get(pk=data["rrule"]) + show = Show.objects.get(pk=show_pk) + + is_repetition = data["is_repetition"] + + # default is `False` + add_business_days_only = ( + data["add_business_days_only"] if "add_business_days_only" in data else False + ) + + # default is `None` + add_days_no = data.get("add_days_no") + by_weekday = data.get("by_weekday") + default_playlist_id = data.get("default_playlist_id") + + first_date = parse_date(data["first_date"]) + start_time = parse_time(data["start_time"]) + end_time = parse_time(data["end_time"]) + + # last_date may not be present in data + if data.get("last_date") is not None: + last_date = parse_date(data["last_date"]) + else: + # If last_date was not set, set it to the end of the year or add x days + if AUTO_SET_LAST_DATE_TO_END_OF_YEAR: + year = timezone.now().year + last_date = timezone.datetime(year, 12, 31).date() + else: + last_date = first_date + timedelta(days=+AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE) + + return Schedule( + pk=pk, + by_weekday=by_weekday, + rrule=rrule, + first_date=first_date, + start_time=start_time, + end_time=end_time, + last_date=last_date, + is_repetition=is_repetition, + default_playlist_id=default_playlist_id, + show=show, + add_days_no=add_days_no, + add_business_days_only=add_business_days_only, + ) + + +def make_conflicts(data: ScheduleData, schedule_pk: int | None, show_pk: int) -> Conflicts: + """ + Retrieves POST vars + Generates a schedule + Generates conflicts: Returns timeslots, collisions, solutions as JSON + Returns conflicts dict + """ + + # Generate schedule to be saved + new_schedule = instantiate_upcoming_schedule(data, show_pk, schedule_pk) + + # Copy if first_date changes for generating timeslots + schedule_copy = new_schedule + + # Generate timeslots + + # If extending: Get last timeslot and start generating from that date on + if schedule_pk is not None: + existing_schedule = Schedule.objects.get(pk=schedule_pk) + + if new_schedule.last_date > existing_schedule.last_date: + last_timeslot = ( + TimeSlot.objects.filter(schedule=existing_schedule).order_by("start").reverse()[0] + ) + schedule_copy.first_date = last_timeslot.start.date() + timedelta(days=1) + + timeslots = generate_timeslots(schedule_copy) + + # Generate conflicts and add schedule + conflicts = generate_conflicts(timeslots) + + # create a new dictionary by adding "schedule" to conflicts + return dict(conflicts, schedule=model_to_dict(new_schedule)) + + +def generate_timeslots(schedule: Schedule) -> list[TimeSlot]: + """ + Returns a list of timeslot objects based on a schedule and its rrule + Returns past timeslots as well, starting from first_date (not today) + """ + timeslots = [] + + # adjust last_date if end_time is after midnight + if schedule.end_time < schedule.start_time: + last_date = schedule.first_date + timedelta(days=+1) + else: + last_date = schedule.first_date + + if schedule.rrule.freq == 3: # daily: Ignore schedule.by_weekday to set by_weekday + by_weekday_start = by_weekday_end = (0, 1, 2, 3, 4, 5, 6) + elif ( + schedule.rrule.freq == 2 + and schedule.rrule.interval == 1 + and schedule.rrule.by_weekdays is None + ): # weekly: Use schedule.by_weekday for by_weekday + by_weekday_start = by_weekday_end = int(schedule.by_weekday) + + # adjust by_weekday_end if end_time is after midnight + if schedule.end_time < schedule.start_time: + by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0 + elif ( + schedule.rrule.freq == 2 + and schedule.rrule.interval == 1 + and schedule.rrule.by_weekdays == "0,1,2,3,4" + ): # weekly on business days: Use schedule.rrule.by_weekdays to set by_weekday + by_weekday_start = by_weekday_end = [ + int(wd) for wd in schedule.rrule.by_weekdays.split(",") + ] + + # adjust by_weekday_end if end_time is after midnight + if schedule.end_time < schedule.start_time: + by_weekday_end = (1, 2, 3, 4, 5) + elif ( + schedule.rrule.freq == 2 + and schedule.rrule.interval == 1 + and schedule.rrule.by_weekdays == "5,6" + ): # weekly on weekends: Use schedule.rrule.by_weekdays to set by_weekday + by_weekday_start = by_weekday_end = [ + int(wd) for wd in schedule.rrule.by_weekdays.split(",") + ] + + # adjust by_weekday_end if end_time is after midnight + if schedule.end_time < schedule.start_time: + by_weekday_end = (6, 0) + elif schedule.rrule.freq == 0: # once: Ignore schedule.by_weekday to set by_weekday + by_weekday_start = by_weekday_end = None + else: + by_weekday_start = by_weekday_end = ( + int(schedule.by_weekday) if schedule.by_weekday is not None else None + ) + + # adjust by_weekday_end if end_time is after midnight + if schedule.end_time < schedule.start_time: + by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0 + + if schedule.rrule.freq == 0: # once: + starts = [datetime.combine(schedule.first_date, schedule.start_time)] + ends = [datetime.combine(last_date, schedule.end_time)] + else: + starts = list( + rrule( + freq=schedule.rrule.freq, + dtstart=datetime.combine(schedule.first_date, schedule.start_time), + interval=schedule.rrule.interval, + until=schedule.last_date + relativedelta(days=+1), + bysetpos=schedule.rrule.by_set_pos, + byweekday=by_weekday_start, + ) + ) + ends = list( + rrule( + freq=schedule.rrule.freq, + dtstart=datetime.combine(last_date, schedule.end_time), + interval=schedule.rrule.interval, + until=schedule.last_date + relativedelta(days=+1), + bysetpos=schedule.rrule.by_set_pos, + byweekday=by_weekday_end, + ) + ) + + for k in range(min(len(starts), len(ends))): + # Correct dates for the (relatively seldom) case if: + # E.g.: 1st Monday from 23:00:00 to 1st Tuesday 00:00:00 + # produces wrong end dates if the 1st Tuesday is before the 1st Monday + # In this case we take the next day instead of rrule's calculated end + if starts[k] > ends[k]: + ends[k] = datetime.combine(starts[k] + relativedelta(days=+1), schedule.end_time) + + """ + Add a number of days to the generated dates? + + This can be helpful for repetitions: + + Examples: + + 1. If RRule is "Every 1st Monday" and we want its repetition always to be on the + following day, the repetition's RRule is the same but add_days_no is 1 + + If we would set the repetition to "Every 1st Tuesday" instead + we will get unmeant results if the 1st Tuesday is before the 1st Monday + (e.g. 1st Tue = May 1 2018, 1st Mon = May 7 2018) + + 2. If RRule is "Every 1st Friday" and we want its repetition always to be on the + following business day, the repetition's RRule is the same but add_days_no is 1 + and add_business_days_only is True (e.g. original date = Fri, March 2 2018; + generated date = Mon, March 5 2018) + + In the UI these can be presets: + "On the following day" (add_days_no=1,add_business_days_only=False) or + "On the following business day" (add_days_no=1,add_business_days_only=True) + + """ + if schedule.add_days_no is not None and schedule.add_days_no > 0: + # If only business days and weekday is Fri, Sat or Sun: add add_days_no beginning + # from Sunday + weekday = datetime.date(starts[k]).weekday() + if schedule.add_business_days_only and weekday > 3: + days_until_sunday = 6 - weekday + starts[k] = starts[k] + relativedelta( + days=+days_until_sunday + schedule.add_days_no + ) + ends[k] = ends[k] + relativedelta(days=+days_until_sunday + schedule.add_days_no) + else: + starts[k] = starts[k] + relativedelta(days=+schedule.add_days_no) + ends[k] = ends[k] + relativedelta(days=+schedule.add_days_no) + + if ends[k].date() > schedule.last_date: + schedule.last_date = ends[k].date() + timeslots.append( + TimeSlot( + schedule=schedule, + start=timezone.make_aware(starts[k], is_dst=True), + end=timezone.make_aware(ends[k], is_dst=True), + ) + ) + + return timeslots + + +def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts: + """ + Tests a list of timeslot objects for colliding timeslots in the database + Returns a list of conflicts containing dicts of projected timeslots, collisions and + solutions + """ + + conflicts: Conflicts = {} + projected = [] + solutions = {} + + # Cycle each timeslot + for ts in timeslots: + # Contains collisions + collisions = [] + + # Contains possible solutions + solution_choices = set() + + # Get collisions for each timeslot + collision_list = list(TimeSlot.objects.get_colliding_timeslots(ts).order_by("start")) + + # Add the projected timeslot + projected_entry = { + "hash": ts.hash, + "start": str(ts.start), + "end": str(ts.end), + } + + for c in collision_list: + # Add the collision + collision = { + "id": c.id, + "start": str(c.start), + "end": str(c.end), + "playlist_id": c.playlist_id, + "show": c.show.id, + "show_name": c.show.name, + "schedule": c.schedule_id, + "memo": c.memo, + } + + # Get note + try: + note = Note.objects.get(timeslot=c.id) + collision["note_id"] = note.pk + except ObjectDoesNotExist: + collision["note_id"] = None + + collisions.append(collision) + + """Determine acceptable solutions""" + + if len(collision_list) > 1: + # If there is more than one collision: Only these two are supported at the + # moment + solution_choices.add("theirs") + solution_choices.add("ours") + else: + # These two are always possible: Either keep theirs and remove ours or vice + # versa + solution_choices.add("theirs") + solution_choices.add("ours") + + # Partly overlapping: projected starts earlier than existing and ends earlier + # + # ex. pr. + # +--+ + # | | + # +--+ | | + # | | +--+ + # | | + # +--+ + # + if ts.end > c.start > ts.start <= c.end: + solution_choices.add("theirs-end") + solution_choices.add("ours-end") + + # Partly overlapping: projected starts later than existing and ends later + # + # ex. pr. + # +--+ + # | | + # | | +--+ + # +--+ | | + # | | + # +--+ + # + if c.start <= ts.start < c.end < ts.end: + solution_choices.add("theirs-start") + solution_choices.add("ours-start") + + # Fully overlapping: projected starts earlier and ends later than existing + # + # ex. pr. + # +--+ + # +--+ | | + # | | | | + # +--+ | | + # +--+ + # + if ts.start < c.start and ts.end > c.end: + solution_choices.add("theirs-end") + solution_choices.add("theirs-start") + solution_choices.add("theirs-both") + + # Fully overlapping: projected starts later and ends earlier than existing + # + # ex. pr. + # +--+ + # | | +--+ + # | | | | + # | | +--+ + # +--+ + # + if ts.start > c.start and ts.end < c.end: + solution_choices.add("ours-end") + solution_choices.add("ours-start") + solution_choices.add("ours-both") + + if len(collisions) > 0: + solutions[ts.hash] = "" + + projected_entry["collisions"] = collisions + projected_entry["solution_choices"] = solution_choices + projected_entry["error"] = None + projected.append(projected_entry) + + conflicts["projected"] = projected + conflicts["solutions"] = solutions + conflicts["notes"] = {} + conflicts["playlists"] = {} + + return conflicts diff --git a/program/tests/__init__.py b/program/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eeef18a2d317dfa39aea5752ac257bc20d0e04bd --- /dev/null +++ b/program/tests/__init__.py @@ -0,0 +1,100 @@ +import datetime + +from django.contrib.auth.models import User +from django.utils.text import slugify +from django.utils.timezone import now +from program.models import Note, RRule, Schedule, Show, TimeSlot + + +class SteeringTestCaseMixin: + base_url = "/api/v1" + + def _url(self, *paths, **kwargs): + url = "/".join(str(p) for p in paths) + "/" + return f"{self.base_url}/{url.format(**kwargs)}" + + def _get_client(self, user=None): + client = self.client_class() + if user: + client.force_authenticate(user=user) + return client + + +class UserMixin: + user_admin: User + user_common: User + + def setUp(self): + self.user_admin = User.objects.create_superuser( + "admin", "admin@aura.radio", password="admin" + ) + self.user_common = User.objects.create_user( + "herbert", "herbert@aura.radio", password="herbert" + ) + + +class ShowMixin: + def _create_show(self, name: str, **kwargs): + kwargs["name"] = name + kwargs.setdefault("slug", slugify(name)) + kwargs.setdefault("short_description", f"The {name} show") + owners = kwargs.pop("owners", []) + show = Show.objects.create(**kwargs) + if owners: + show.owners.set(owners) + return show + + +class ScheduleMixin: + def _get_rrule(self): + rrule = RRule.objects.first() + if rrule is None: + rrule = RRule.objects.create(name="once", freq=0) + return rrule + + def _create_schedule(self, show: Show, **kwargs): + _first_date = kwargs.get("first_date", now().date()) + kwargs["show"] = show + kwargs.setdefault("first_date", _first_date) + kwargs.setdefault("start_time", "08:00") + kwargs.setdefault("last_date", _first_date + datetime.timedelta(days=365)) + kwargs.setdefault("end_time", "09:00") + kwargs.setdefault("rrule", self._get_rrule()) + return Schedule.objects.create(**kwargs) + + +class TimeSlotMixin: + def _create_timeslot(self, schedule: Schedule, **kwargs): + _start = kwargs.get("start", now()) + kwargs.setdefault("schedule", schedule) + kwargs.setdefault("show", schedule.show) + kwargs.setdefault("start", _start) + kwargs.setdefault("end", _start + datetime.timedelta(hours=1)) + return TimeSlot.objects.create(**kwargs) + + +class NoteMixin: + def _create_note(self, timeslot: TimeSlot, **kwargs): + note_count = Note.objects.all().count() + _title = kwargs.get("title", f"a random note #{note_count}") + kwargs["timeslot"] = timeslot + kwargs["title"] = _title + kwargs.setdefault("slug", slugify(_title)) + return Note.objects.create(**kwargs) + + def _create_random_note_content(self, **kwargs): + note_count = Note.objects.all().count() + _title = kwargs.get("title", f"a random note #{note_count}") + kwargs["title"] = _title + kwargs.setdefault("slug", slugify(_title)) + kwargs.setdefault("content", "some random content") + kwargs.setdefault("contributors", []) + return kwargs + + +class ProgramModelMixin(ShowMixin, ScheduleMixin, TimeSlotMixin, NoteMixin): + pass + + +class BaseMixin(UserMixin, ProgramModelMixin, SteeringTestCaseMixin): + pass diff --git a/program/tests/test_notes.py b/program/tests/test_notes.py new file mode 100644 index 0000000000000000000000000000000000000000..d21fdadcaabcc7bca81efd6da49eda400df4e325 --- /dev/null +++ b/program/tests/test_notes.py @@ -0,0 +1,124 @@ +from rest_framework.test import APITransactionTestCase + +from program import tests +from program.models import Schedule, Show + + +class NoteViewTestCase(tests.BaseMixin, APITransactionTestCase): + reset_sequences = True + + show_beatbetrieb: Show + schedule_beatbetrieb: Schedule + show_musikrotation: Show + schedule_musikrotation: Schedule + + def setUp(self) -> None: + super().setUp() + self.show_beatbetrieb = self._create_show("Beatbetrieb") + self.schedule_beatbetrieb = self._create_schedule(self.show_beatbetrieb) + self.show_musikrotation = self._create_show("Musikrotation", owners=[self.user_common]) + self.schedule_musikrotation = self._create_schedule( + self.show_musikrotation, start_time="10:00", end_time="12:00" + ) + + def test_everyone_can_read_notes(self): + self._create_note(self._create_timeslot(schedule=self.schedule_beatbetrieb)) + self._create_note(self._create_timeslot(schedule=self.schedule_musikrotation)) + res = self._get_client().get(self._url("notes")) + self.assertEqual(len(res.data), 2) + + def test_common_users_can_create_notes_for_owned_shows(self): + ts = self._create_timeslot(schedule=self.schedule_musikrotation) + client = self._get_client(self.user_common) + endpoint = self._url("notes") + res = client.post( + endpoint, self._create_random_note_content(timeslot=ts.id), format="json" + ) + self.assertEqual(res.status_code, 201) + + def test_common_users_cannot_create_notes_for_foreign_shows(self): + ts = self._create_timeslot(schedule=self.schedule_beatbetrieb) + client = self._get_client(self.user_common) + endpoint = self._url("notes") + res = client.post( + endpoint, self._create_random_note_content(timeslot=ts.id), format="json" + ) + self.assertEqual(res.status_code, 404) + + def test_common_user_can_update_owned_shows(self): + ts = self._create_timeslot(schedule=self.schedule_musikrotation) + note = self._create_note(ts) + client = self._get_client(self.user_common) + new_note_content = self._create_random_note_content(title="meh") + res = client.put(self._url("notes", note.id), new_note_content, format="json") + self.assertEqual(res.status_code, 200) + + def test_common_user_cannot_update_notes_of_foreign_shows(self): + ts = self._create_timeslot(schedule=self.schedule_beatbetrieb) + note = self._create_note(ts) + client = self._get_client(self.user_common) + new_note_content = self._create_random_note_content(title="meh") + res = client.put(self._url("notes", note.id), new_note_content, format="json") + self.assertEqual(res.status_code, 404) + + def test_admin_can_create_notes_for_all_timeslots(self): + timeslot = self._create_timeslot(schedule=self.schedule_musikrotation) + client = self._get_client(self.user_admin) + res = client.post( + self._url("notes"), + self._create_random_note_content(timeslot=timeslot.id), + format="json", + ) + self.assertEqual(res.status_code, 201) + + def test_notes_can_be_created_through_nested_routes(self): + client = self._get_client(self.user_admin) + + # /shows/{pk}/notes/ + ts1 = self._create_timeslot(schedule=self.schedule_musikrotation) + url = self._url("shows", self.show_musikrotation.id, "notes") + note = self._create_random_note_content(title="meh", timeslot=ts1.id) + res = client.post(url, note, format="json") + self.assertEqual(res.status_code, 201) + + # /shows/{pk}/timeslots/{pk}/note/ + ts2 = self._create_timeslot(schedule=self.schedule_musikrotation) + url = self._url("shows", self.show_musikrotation, "timeslots", ts2.id, "note") + note = self._create_random_note_content(title="cool") + res = client.post(url, note, format="json") + self.assertEqual(res.status_code, 201) + + def test_notes_can_be_filtered_through_nested_routes_and_query_params(self): + client = self._get_client() + + ts1 = self._create_timeslot(schedule=self.schedule_musikrotation) + ts2 = self._create_timeslot(schedule=self.schedule_beatbetrieb) + ts3 = self._create_timeslot(schedule=self.schedule_beatbetrieb) + n1 = self._create_note(timeslot=ts1) + n2 = self._create_note(timeslot=ts2) + n3 = self._create_note(timeslot=ts3) + + def _get_ids(res): + return set(ts["id"] for ts in res.data) + + # /shows/{pk}/notes/ + query_res = client.get(self._url("notes") + f"?show={self.show_beatbetrieb.id}") + route_res = client.get(self._url("shows", self.show_beatbetrieb.id, "notes")) + ids = {n2.id, n3.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) + + query_res = client.get(self._url("notes") + f"?show={self.show_musikrotation.id}") + route_res = client.get(self._url("shows", self.show_musikrotation.id, "notes")) + ids = {n1.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) + + # /shows/{pk}/timeslots/{pk}/note/ + query_res = client.get(self._url("notes") + f"?timeslot={ts2.id}") + route_res = client.get( + self._url("shows", self.show_beatbetrieb.id, "timeslots", ts2.id, "note") + ) + ids = {n2.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) diff --git a/program/utils.py b/program/utils.py index d21eaaf81ea9f1e66d2b4551d59a190fdd1cc262..3295beb3d813eaf1adf42f84dfc52e3a0afa7d20 100644 --- a/program/utils.py +++ b/program/utils.py @@ -19,6 +19,7 @@ # import json +import typing from datetime import date, datetime, time from typing import Dict, Optional, Tuple, Union @@ -28,6 +29,9 @@ from rest_framework import exceptions from django.utils import timezone from steering.settings import CBA_AJAX_URL, CBA_API_KEY, DEBUG +if typing.TYPE_CHECKING: + from program.models import Host, Note, Show + def parse_datetime(date_string: str) -> datetime: """ @@ -56,7 +60,11 @@ def parse_time(date_string: str) -> time: """ parse a time string and return a time object """ - return datetime.strptime(date_string, "%H:%M:%S").time() + + if len(date_string) == 5: + return datetime.strptime(date_string, "%H:%M").time() + else: + return datetime.strptime(date_string, "%H:%M:%S").time() def get_audio_url(cba_id: Optional[int]) -> str: @@ -110,6 +118,16 @@ def get_values( return int_if_digit(values[0]) +def delete_links(instance: Union["Host", "Note", "Show"]) -> Union["Host", "Note", "Show"]: + """Delete the links associated with the instance.""" + + if instance.links.count() > 0: + for link in instance.links.all(): + link.delete(keep_parents=True) + + return instance + + class DisabledObjectPermissionCheckMixin: """ At the time of writing permission checks were entirely circumvented by manual diff --git a/program/views.py b/program/views.py index 66804e342b6a5d6743087b77d2efb1658a476acc..c4ece009fc5dc94685e94548d4905650c102ae39 100644 --- a/program/views.py +++ b/program/views.py @@ -26,12 +26,14 @@ from textwrap import dedent from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import mixins, permissions, status, viewsets +from rest_framework.exceptions import ValidationError from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response from django.contrib.auth.models import User -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 +from django.core.exceptions import FieldError +from django.http import Http404, HttpResponse +from django.shortcuts import get_list_or_404, get_object_or_404 from django.utils import timezone from django.utils.translation import gettext as _ from program import filters @@ -39,9 +41,13 @@ from program.models import ( Category, FundingCategory, Host, + Image, Language, + LicenseType, + LinkType, MusicFocus, Note, + RRule, Schedule, ScheduleConflictError, Show, @@ -54,9 +60,13 @@ from program.serializers import ( ErrorSerializer, FundingCategorySerializer, HostSerializer, + ImageSerializer, LanguageSerializer, + LicenseTypeSerializer, + LinkTypeSerializer, MusicFocusSerializer, NoteSerializer, + RRuleSerializer, ScheduleConflictResponseSerializer, ScheduleCreateUpdateRequestSerializer, ScheduleDryRunResponseSerializer, @@ -68,6 +78,7 @@ from program.serializers import ( TypeSerializer, UserSerializer, ) +from program.services import resolve_conflicts from program.utils import ( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, @@ -94,7 +105,8 @@ def timeslot_entry(*, timeslot: TimeSlot) -> dict: "end": timezone.make_naive(timeslot.end).strftime("%Y-%m-%dT%H:%M:%S"), "title": title, "schedule_id": schedule.id, - "is_repetition": timeslot.is_repetition, + # `Timeslot.repetition_of` is a foreign key that can be null + "is_repetition": timeslot.repetition_of.id if timeslot.repetition_of else False, "playlist_id": playlist_id, "schedule_default_playlist_id": schedule.default_playlist_id, "show_default_playlist_id": show.default_playlist_id, @@ -287,6 +299,63 @@ class APIUserViewSet( return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class APIImageViewSet(viewsets.ModelViewSet): + queryset = Image.objects.all() + serializer_class = ImageSerializer + permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] + pagination_class = LimitOffsetPagination + + def get_queryset(self): + """The queryset contains only images where the owner is the request's user.""" + + return Image.objects.filter(owner=self.request.user.username) + + def create(self, request, *args, **kwargs): + """Create an Image instance. Any user can create an image.""" + + serializer = ImageSerializer( + data=request.data, + context={"owner": request.user.username}, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + """Update an Image instance. Only the creator can update an image.""" + + image = self.get_object() + + if image.owner != request.user.username: + return Response(status=status.HTTP_403_FORBIDDEN) + + serializer = ImageSerializer( + image, + data=request.data, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + """Destroy an Image instance. Only the owner can delete an image.""" + + image = self.get_object() + + if image.owner != request.user.username: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + image.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema_view( create=extend_schema(summary="Create a new show."), retrieve=extend_schema(summary="Retrieve a single show."), @@ -302,6 +371,23 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): pagination_class = LimitOffsetPagination filterset_class = filters.ShowFilterSet + def list(self, request, *args, **kwargs): + filter_kwargs = {} + for key, value in request.query_params.items(): + filter_kwargs[key] = value + + try: + queryset = get_list_or_404(self.get_queryset(), **filter_kwargs) + except FieldError: + queryset = None + + if page := self.paginate_queryset(queryset) is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def get_object(self): queryset = self.filter_queryset(self.get_queryset()) lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field @@ -323,7 +409,9 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) - serializer = ShowSerializer(data=request.data) + serializer = ShowSerializer( + data=request.data, context={"created_by": request.user.username} + ) if serializer.is_valid(): serializer.save() @@ -343,8 +431,14 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): ): return Response(status=status.HTTP_401_UNAUTHORIZED) + partial = kwargs.get("partial", False) show = self.get_object() - serializer = ShowSerializer(show, data=request.data, context={"user": request.user}) + serializer = ShowSerializer( + show, + data=request.data, + context={"updated_by": request.user.username}, + partial=partial, + ) if serializer.is_valid(): # Common users mustn't edit the show's name @@ -355,6 +449,10 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def partial_update(self, request, *args, **kwargs): + kwargs["partial"] = True + return self.update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): """ Only admins may delete shows. @@ -368,6 +466,15 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +@extend_schema_view( + retrieve=extend_schema(summary="Retrieve a single rrule."), + list=extend_schema(summary="List all rrule."), +) +class APIRRuleViewSet(viewsets.ModelViewSet): + queryset = RRule.objects.all() + serializer_class = RRuleSerializer + + @extend_schema_view( create=extend_schema( summary="Create a new schedule.", @@ -425,7 +532,7 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): * 'This change on the timeslot is not allowed.' When adding: There was a change in the schedule's data during conflict resolution. - When updating: Fields 'start', 'end', 'byweekday' or 'rrule' have changed, + When updating: Fields 'start', 'end', 'by_weekday' or 'rrule' have changed, which is not allowed. * 'No solution given': No solution was provided for the conflict in `solutions`. Provide a value of `solution_choices`. @@ -510,7 +617,7 @@ class APIScheduleViewSet( return Response(status=status.HTTP_400_BAD_REQUEST) try: - resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + resolution = resolve_conflicts(request.data, pk, show_pk) except ScheduleConflictError as exc: return Response(exc.conflicts, status.HTTP_409_CONFLICT) @@ -553,7 +660,7 @@ class APIScheduleViewSet( return Response(serializer.data) try: - resolution = Schedule.resolve_conflicts(request.data, schedule.pk, schedule.show.pk) + resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk) except ScheduleConflictError as exc: return Response(exc.conflicts, status.HTTP_409_CONFLICT) @@ -627,7 +734,7 @@ class APITimeSlotViewSet( # We do this because the Dashboard needs to update the repetition timeslot as well # but with another playlist containing the recording instead of the original playlist if first_repetition := TimeSlot.objects.filter( - show=show_pk, start__gt=timeslot.start, is_repetition=True + show=show_pk, start__gt=timeslot.start, repetition_of=timeslot ).first(): serializer = TimeSlotSerializer(first_repetition) return Response(serializer.data) @@ -667,81 +774,45 @@ class APINoteViewSet( viewsets.ModelViewSet, ): ROUTE_FILTER_LOOKUPS = { - "show_pk": "show", + "show_pk": "timeslot__show", "timeslot_pk": "timeslot", } - queryset = Note.objects.all() serializer_class = NoteSerializer - permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] + permission_classes = [permissions.IsAuthenticatedOrReadOnly] pagination_class = LimitOffsetPagination - filter_class = filters.NoteFilterSet - - def create(self, request, *args, **kwargs): - """ - Only admins can create new notes. - """ - show_pk, timeslot_pk = get_values(self.kwargs, "show_pk", "timeslot_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) + filterset_class = filters.NoteFilterSet - serializer = NoteSerializer( - data={"show": show_pk, "timeslot": timeslot_pk} | request.data, - context={"user_id": request.user.id}, - ) - - if serializer.is_valid(): - hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True)) - if not request.user.is_superuser and request.data["host"] not in hosts: - serializer.validated_data["host"] = None - - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def update(self, request, *args, **kwargs): - """ - Only admins can update existing notes. - """ - show_pk = get_values(self.kwargs, "show_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) - - note = self.get_object() - serializer = NoteSerializer(note, data=request.data) - - if serializer.is_valid(): - hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True)) - # Don't assign a host the user mustn't edit. Reassign the original value instead - if not request.user.is_superuser and int(request.data["host"]) not in hosts: - serializer.validated_data["host"] = Host.objects.filter(pk=note.host_id)[0] - - serializer.save() - return Response(serializer.data) - - return Response(status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - """ - Only admins can delete existing notes. - """ - show_pk = get_values(self.kwargs, "show_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) + def get_queryset(self): + qs = super().get_queryset().order_by("slug") + # Users should always be able to see notes + if self.request.method not in permissions.SAFE_METHODS: + # If the request is not by an admin, + # check that the timeslot is owned by the current user. + if not self.request.user.is_superuser: + qs = qs.filter(timeslot__show__owners=self.request.user) + return qs + + def _get_timeslot(self): + # TODO: Once we remove nested routes, timeslot ownership + # should be checked in a permission class. + timeslot_pk = self.request.data.get("timeslot", None) + if timeslot_pk is None: + timeslot_pk = get_values(self.kwargs, "timeslot_pk") + if timeslot_pk is None: + raise ValidationError({"timeslot": [_("This field is required.")]}, code="required") + qs = TimeSlot.objects.all() + if not self.request.user.is_superuser: + qs = qs.filter(show__owners=self.request.user) + try: + return qs.get(pk=timeslot_pk) + except TimeSlot.DoesNotExist: + raise Http404() - self.get_object().delete() - - return Response(status=status.HTTP_204_NO_CONTENT) + def perform_create(self, serializer): + # TODO: Once we remove nested routes, this should be removed + # and timeslot should be required in the serializer again. + serializer.save(timeslot=self._get_timeslot()) class ActiveFilterMixin: @@ -838,3 +909,58 @@ class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet): queryset = Host.objects.all() serializer_class = HostSerializer pagination_class = LimitOffsetPagination + + def create(self, request, *args, **kwargs): + serializer = HostSerializer( + data=request.data, context={"created_by": request.user.username} + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + partial = kwargs.get("partial", False) + host = self.get_object() + serializer = HostSerializer( + host, + data=request.data, + context={"updated_by": request.user.username}, + partial=partial, + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, *args, **kwargs): + kwargs["partial"] = True + return self.update(request, *args, **kwargs) + + +@extend_schema_view( + create=extend_schema(summary="Create a new link type."), + retrieve=extend_schema(summary="Retrieve a single link type."), + update=extend_schema(summary="Update an existing link type."), + partial_update=extend_schema(summary="Partially update an existing link type."), + destroy=extend_schema(summary="Delete an existing link type."), + list=extend_schema(summary="List all link types."), +) +class APILinkTypeViewSet(viewsets.ModelViewSet): + queryset = LinkType.objects.all() + serializer_class = LinkTypeSerializer + + +@extend_schema_view( + create=extend_schema(summary="Create a new license type."), + retrieve=extend_schema(summary="Retrieve a single license type."), + update=extend_schema(summary="Update an existing license type."), + partial_update=extend_schema(summary="Partially update an existing license type."), + destroy=extend_schema(summary="Delete an existing license type."), + list=extend_schema(summary="List all license types."), +) +class APILicenseTypeViewSet(viewsets.ModelViewSet): + queryset = LicenseType.objects.all() + serializer_class = LicenseTypeSerializer diff --git a/steering/settings.py b/steering/settings.py index d9f569510f0dfbeeafd2686318c50f239797a212..a646b46ef7c0341b8d09fc660f003c1148e85001 100644 --- a/steering/settings.py +++ b/steering/settings.py @@ -110,7 +110,6 @@ INSTALLED_APPS = ( "django.contrib.admin", "django.contrib.staticfiles", "program", - "profile", "versatileimagefield", "rest_framework", "rest_framework_nested", diff --git a/steering/urls.py b/steering/urls.py index f0a4311fb4e5e3607b25c6f688feb86c40aaaee4..70df93704acabc6716f9789ba03adba5a8f0155e 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -21,15 +21,21 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework_nested import routers +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path from program.views import ( APICategoryViewSet, APIFundingCategoryViewSet, APIHostViewSet, + APIImageViewSet, APILanguageViewSet, + APILicenseTypeViewSet, + APILinkTypeViewSet, APIMusicFocusViewSet, APINoteViewSet, + APIRRuleViewSet, APIScheduleViewSet, APIShowViewSet, APITimeSlotViewSet, @@ -52,9 +58,13 @@ router.register(r"notes", APINoteViewSet) router.register(r"categories", APICategoryViewSet) router.register(r"topics", APITopicViewSet) router.register(r"types", APITypeViewSet) -router.register(r"musicfocus", APIMusicFocusViewSet) -router.register(r"fundingcategories", APIFundingCategoryViewSet) +router.register(r"music-focus", APIMusicFocusViewSet) +router.register(r"funding-categories", APIFundingCategoryViewSet) router.register(r"languages", APILanguageViewSet) +router.register(r"license-types", APILicenseTypeViewSet) +router.register(r"link-types", APILinkTypeViewSet) +router.register(r"rrules", APIRRuleViewSet) +router.register(r"images", APIImageViewSet) # Nested Routers @@ -68,25 +78,17 @@ show_router.register(r"notes", APINoteViewSet, basename="show-notes") # /shows/1/timeslots show_router.register(r"timeslots", APITimeSlotViewSet, basename="show-timeslots") -show_timeslot_router = routers.NestedSimpleRouter( - show_router, r"timeslots", lookup="timeslot" -) +show_timeslot_router = routers.NestedSimpleRouter(show_router, r"timeslots", lookup="timeslot") # /shows/1/timeslots/1/note/ show_timeslot_router.register(r"note", APINoteViewSet, basename="show-timeslots-note") # /shows/1/schedules -schedule_router = routers.NestedSimpleRouter( - show_router, r"schedules", lookup="schedule" -) +schedule_router = routers.NestedSimpleRouter(show_router, r"schedules", lookup="schedule") # /shows/1/schedules/1/timeslots -schedule_router.register( - r"timeslots", APITimeSlotViewSet, basename="schedule-timeslots" -) -timeslot_router = routers.NestedSimpleRouter( - schedule_router, r"timeslots", lookup="timeslot" -) +schedule_router.register(r"timeslots", APITimeSlotViewSet, basename="schedule-timeslots") +timeslot_router = routers.NestedSimpleRouter(schedule_router, r"timeslots", lookup="timeslot") # /shows/1/schedules/1/timeslots/1/note timeslot_router.register(r"note", APINoteViewSet, basename="timeslots-note") @@ -108,4 +110,4 @@ urlpatterns = [ name="swagger-ui", ), path("admin/", admin.site.urls), -] +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)