From 0d6d81df7ee75b93eda2a9bfb42a8cb0090c085f Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stefan=20K=C3=B6gl?= Date: Fri, 31 Oct 2014 16:56:15 +0100 Subject: [PATCH] [UserSettings] refactor, add tests --- mygpo/api/__init__.py | 3 +- mygpo/api/advanced/__init__.py | 6 +- mygpo/api/advanced/settings.py | 183 +++++++++------------ mygpo/api/backend.py | 2 +- mygpo/api/urls.py | 6 +- mygpo/core/models.py | 40 ----- mygpo/decorators.py | 2 +- mygpo/flattr.py | 12 +- mygpo/maintenance/merge.py | 2 +- mygpo/settings.py | 1 + mygpo/share/userpage.py | 2 +- mygpo/subscriptions/__init__.py | 7 +- mygpo/subscriptions/admin.py | 14 +- .../migrations/0003_remove_podcastconfig.py | 31 ++++ mygpo/subscriptions/models.py | 37 +---- mygpo/subscriptions/views.py | 2 +- .../migrations/0012_remove_userprofile_settings.py | 19 +++ mygpo/users/models.py | 13 +- mygpo/usersettings/__init__.py | 0 mygpo/usersettings/admin.py | 15 ++ mygpo/usersettings/migrations/0001_initial.py | 41 +++++ .../usersettings/migrations/0002_move_existing.py | 59 +++++++ .../migrations/0003_meta_verbose_name.py | 18 ++ mygpo/usersettings/migrations/__init__.py | 0 mygpo/usersettings/models.py | 123 ++++++++++++++ mygpo/usersettings/tests.py | 95 +++++++++++ mygpo/usersettings/views.py | 3 + mygpo/web/views/__init__.py | 4 +- mygpo/web/views/episode.py | 2 +- mygpo/web/views/podcast.py | 12 +- mygpo/web/views/settings.py | 48 +++--- 31 files changed, 556 insertions(+), 246 deletions(-) rewrite mygpo/api/advanced/settings.py (75%) create mode 100644 mygpo/subscriptions/migrations/0003_remove_podcastconfig.py create mode 100644 mygpo/users/migrations/0012_remove_userprofile_settings.py create mode 100644 mygpo/usersettings/__init__.py create mode 100644 mygpo/usersettings/admin.py create mode 100644 mygpo/usersettings/migrations/0001_initial.py create mode 100644 mygpo/usersettings/migrations/0002_move_existing.py create mode 100644 mygpo/usersettings/migrations/0003_meta_verbose_name.py create mode 100644 mygpo/usersettings/migrations/__init__.py create mode 100644 mygpo/usersettings/models.py create mode 100644 mygpo/usersettings/tests.py create mode 100644 mygpo/usersettings/views.py diff --git a/mygpo/api/__init__.py b/mygpo/api/__init__.py index 9e40850c..927edc14 100644 --- a/mygpo/api/__init__.py +++ b/mygpo/api/__init__.py @@ -69,7 +69,8 @@ class APIView(View): return parse_request_body(request) except (JSONDecodeError, UnicodeDecodeError, ValueError) as e: msg = u'Could not decode request body for user {}: {}'.format( - username, request.body.decode('ascii', errors='replace')) + request.user.username, + request.body.decode('ascii', errors='replace')) logger.warn(msg, exc_info=True) raise RequestException(msg) diff --git a/mygpo/api/advanced/__init__.py b/mygpo/api/advanced/__init__.py index a33ec755..a9315c21 100644 --- a/mygpo/api/advanced/__init__.py +++ b/mygpo/api/advanced/__init__.py @@ -54,10 +54,6 @@ import logging logger = logging.getLogger(__name__) -class RequestException(Exception): - """ Raised if the request is malfored or otherwise invalid """ - - # keys that are allowed in episode actions EPISODE_ACTION_KEYS = ('position', 'episode', 'action', 'device', 'timestamp', 'started', 'total', 'podcast') @@ -207,7 +203,7 @@ def episode_action_json(history, user): def update_episodes(user, actions, now, ua_string): update_urls = [] - auto_flattr = user.profile.get_wksetting(FLATTR_AUTO) + auto_flattr = user.profile.settings.get_wksetting(FLATTR_AUTO) # group all actions by their episode for action in actions: diff --git a/mygpo/api/advanced/settings.py b/mygpo/api/advanced/settings.py dissimilarity index 75% index c340c556..8657cae0 100644 --- a/mygpo/api/advanced/settings.py +++ b/mygpo/api/advanced/settings.py @@ -1,103 +1,80 @@ -# -# This file is part of my.gpodder.org. -# -# my.gpodder.org 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. -# -# my.gpodder.org 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 my.gpodder.org. If not, see . -# - -from django.http import HttpResponseBadRequest, Http404, HttpResponseNotFound -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.cache import never_cache -from django.shortcuts import get_object_or_404 - -from mygpo.decorators import allowed_methods, cors_origin -from mygpo.podcasts.models import Podcast, Episode -from mygpo.utils import parse_request_body -from mygpo.api.basic_auth import require_valid_user, check_username -from mygpo.api.httpresponse import JsonResponse -from mygpo.users.models import Client - - -@csrf_exempt -@require_valid_user -@check_username -@never_cache -@allowed_methods(['GET', 'POST']) -@cors_origin() -def main(request, username, scope): - - def user_settings(user): - return user, user, None - - def device_settings(user, uid): - device = user.get_device_by_uid(uid) - - # get it from the user directly so that changes - # to settings_obj are reflected in user (bug 1344) - settings_obj = user.get_device_by_uid(uid) - - return user, settings_obj, None - - def podcast_settings(user, url): - podcast = get_object_or_404(Podcast, urls__url=url) - # TODO: fix - return None, None, None - - def episode_settings(user, url, podcast_url): - try: - episode = Episode.objects.filter(podcast__urls__url=podcast_url, - urls__url=url).get() - except Episode.DoesNotExist: - raise Http404 - - return None, None, None - - models = dict( - account = lambda: user_settings(request.user), - device = lambda: device_settings(request.user, request.GET.get('device', '')), - podcast = lambda: podcast_settings(request.user, request.GET.get('podcast', '')), - episode = lambda: episode_settings(request.user, request.GET.get('episode', ''), request.GET.get('podcast', '')) - ) - - - if scope not in models.keys(): - return HttpResponseBadRequest('undefined scope %s' % scope) - - try: - base_obj, settings_obj, db = models[scope]() - except Client.DoesNotExist as e: - return HttpResponseNotFound(str(e)) - - if request.method == 'GET': - return JsonResponse( settings_obj.settings ) - - elif request.method == 'POST': - actions = parse_request_body(request) - - # TODO: handle well-known settings that trigger some behavior (eg - # marking an episode as favorite) - - ret = update_settings(settings_obj, actions) - db.save_doc(base_obj) - return JsonResponse(ret) - - -def update_settings(obj, actions): - for key, value in actions.get('set', {}).iteritems(): - obj.settings[key] = value - - for key in actions.get('remove', []): - if key in obj.settings: - del obj.settings[key] - - return obj.settings +# +# This file is part of my.gpodder.org. +# +# my.gpodder.org 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. +# +# my.gpodder.org 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 my.gpodder.org. If not, see . +# + +import json + +from django.shortcuts import get_object_or_404 + +from mygpo.api import APIView, RequestException +from mygpo.podcasts.models import Podcast, Episode +from mygpo.usersettings.models import UserSettings +from mygpo.api.httpresponse import JsonResponse + + +class SettingsAPI(APIView): + """ Settings API + + wiki.gpodder.org/wiki/Web_Services/API_2/Settings """ + + def get(self, request, username, scope): + """ Get settings for scope object """ + user = request.user + scope = self.get_scope(request, scope) + settings = UserSettings.objects.get_for_scope(user, scope) + return JsonResponse(settings.as_dict()) + + def post(self, request, username, scope): + """ Update settings for scope object """ + user = request.user + scope = self.get_scope(request, scope) + actions = self.parsed_body(request) + settings = UserSettings.objects.get_for_scope(user, scope) + resp = self.update_settings(settings, actions) + return JsonResponse(resp) + + def get_scope(self, request, scope): + """ Get the scope object """ + if scope == 'account': + return None + + if scope == 'device': + uid = request.GET.get('device', '') + return request.user.client_set.get(uid=uid) + + episode_url = request.GET.get('episode', '') + podcast_url = request.GET.get('podcast', '') + + if scope == 'podcast': + return get_object_or_404(Podcast, urls__url=podcast_url) + + if scope == 'episode': + podcast = get_object_or_404(Podcast, urls__url=podcast_url) + return get_object_or_404(Episode, podcast=podcast, + urls__url=episode_url) + + raise RequestException('undefined scope %s' % scope) + + def update_settings(self, settings, actions): + """ Update the settings according to the actions """ + for key, value in actions.get('set', {}).iteritems(): + settings.set_setting(key, value) + + for key in actions.get('remove', []): + settings.del_setting(key) + + settings.save() + return settings.as_dict() diff --git a/mygpo/api/backend.py b/mygpo/api/backend.py index 4e13c72f..d683b294 100644 --- a/mygpo/api/backend.py +++ b/mygpo/api/backend.py @@ -28,7 +28,7 @@ def get_device(user, uid, user_agent, undelete=True): If the device has been deleted and undelete=True, it is undeleted. """ - store_ua = user.profile.get_wksetting(STORE_UA) + store_ua = user.profile.settings.get_wksetting(STORE_UA) save = False diff --git a/mygpo/api/urls.py b/mygpo/api/urls.py index 2977324f..e62aa410 100644 --- a/mygpo/api/urls.py +++ b/mygpo/api/urls.py @@ -19,6 +19,7 @@ urlpatterns += patterns('mygpo.api.simple', from mygpo.api.subscriptions import SubscriptionsAPI from mygpo.api.advanced.updates import DeviceUpdates from mygpo.api.advanced.episode import ChaptersAPI +from mygpo.api.advanced.settings import SettingsAPI urlpatterns += patterns('mygpo.api.advanced', url(r'^api/(?P[12])/subscriptions/(?P[\w.+-]+)/(?P[\w.-]+)\.json', @@ -39,7 +40,10 @@ urlpatterns += patterns('mygpo.api.advanced', (r'^api/2/updates/(?P[\w.+-]+)/(?P[\w.-]+)\.json', DeviceUpdates.as_view()), - (r'^api/2/settings/(?P[\w.+-]+)/(?Paccount|device|podcast|episode)\.json', 'settings.main'), + url(r'^api/2/settings/(?P[\w.+-]+)/(?Paccount|device|podcast|episode)\.json', + SettingsAPI.as_view(), + name='settings-api'), + (r'^api/2/favorites/(?P[\w.+-]+).json', 'favorites'), (r'^api/2/lists/(?P[\w.+-]+)/create\.(?P\w+)', 'lists.create'), diff --git a/mygpo/core/models.py b/mygpo/core/models.py index 4d2f75cd..b246ab99 100644 --- a/mygpo/core/models.py +++ b/mygpo/core/models.py @@ -8,9 +8,6 @@ from uuidfield import UUIDField from django.db import models, connection -import logging -logger = logging.getLogger(__name__) - class UUIDModel(models.Model): """ Models that have an UUID as primary key """ @@ -34,43 +31,6 @@ class TwitterModel(models.Model): abstract = True -class SettingsModel(models.Model): - """ A model that can store arbitrary settings as JSON """ - - settings = models.TextField(null=False, default='{}') - - class Meta: - abstract = True - - def get_wksetting(self, setting): - """ returns the value of a well-known setting """ - try: - settings = json.loads(self.settings) - except ValueError as ex: - logger.warn('Decoding settings failed: {msg}'.format(msg=str(ex))) - return None - - return settings.get(setting.name, setting.default) - - def set_wksetting(self, setting, value): - try: - settings = json.loads(self.settings) - except ValueError as ex: - logger.warn('Decoding settings failed: {msg}'.format(msg=str(ex))) - settings = {} - settings[setting.name] = value - self.settings = json.dumps(settings) - - def get_setting(self, name, default): - settings = json.loads(self.settings) - return settings.get(name, default) - - def set_setting(self, name, value): - settings = json.loads(self.settings) - settings[name] = value - self.settings = json.dumps(settings) - - class GenericManager(models.Manager): """ Generic manager methods """ diff --git a/mygpo/decorators.py b/mygpo/decorators.py index 99bee437..1c9b1869 100644 --- a/mygpo/decorators.py +++ b/mygpo/decorators.py @@ -49,7 +49,7 @@ def requires_token(token_name, denied_template=None): User = get_user_model() user = get_object_or_404(User, username=username) - token = user.profile.get_token(token_name) + token = user.profile.settings.get_token(token_name) u_token = request.GET.get('token', '') if token == '' or token == u_token: diff --git a/mygpo/flattr.py b/mygpo/flattr.py index dfb7e535..4a8d3c74 100644 --- a/mygpo/flattr.py +++ b/mygpo/flattr.py @@ -55,8 +55,8 @@ class Flattr(object): # Inject username and password into the request URL url = utils.url_add_authentication(url, settings.FLATTR_KEY, settings.FLATTR_SECRET) - elif self.user.profile.get_setting('flattr_token', ''): - headers['Authorization'] = 'Bearer ' + self.user.profile.get_wksetting(FLATTR_TOKEN) + elif self.user.profile.settings.get_setting('flattr_token', ''): + headers['Authorization'] = 'Bearer ' + self.user.profile.settings.get_wksetting(FLATTR_TOKEN) if data is not None: data = json.dumps(data) @@ -80,7 +80,7 @@ class Flattr(object): } def has_token(self): - return bool(self.user.profile.get_wksetting(FLATTR_TOKEN)) + return bool(self.user.profile.settings.get_wksetting(FLATTR_TOKEN)) def process_retrieved_code(self, url): url_parsed = urlparse.urlparse(url) @@ -114,7 +114,7 @@ class Flattr(object): flattrs ... The number of Flattrs this thing received flattred ... True if this user already flattred this thing """ - if not self.user.profile.get_wksetting(FLATTR_TOKEN): + if not self.user.profile.settings.get_wksetting(FLATTR_TOKEN): return (0, False) quote_url = urllib.quote_plus(utils.sanitize_encoding(payment_url)) @@ -124,7 +124,7 @@ class Flattr(object): def get_auth_username(self): - if not self.user.profile.get_wksetting(FLATTR_TOKEN): + if not self.user.profile.settings.get_wksetting(FLATTR_TOKEN): return '' data = self.request(self.USER_INFO_URL) @@ -165,7 +165,7 @@ class Flattr(object): def get_autosubmit_url(self, thing): """ returns the auto-submit URL for the given FlattrThing """ - publish_username = self.user.profile.get_wksetting(FLATTR_USERNAME) + publish_username = self.user.profile.settings.get_wksetting(FLATTR_USERNAME) if not publish_username: return None diff --git a/mygpo/maintenance/merge.py b/mygpo/maintenance/merge.py index 2bf38a36..e3df02be 100644 --- a/mygpo/maintenance/merge.py +++ b/mygpo/maintenance/merge.py @@ -10,7 +10,7 @@ from mygpo.podcasts.models import (MergedUUID, ScopedModel, OrderedModel, Slug, from mygpo import utils from mygpo.history.models import HistoryEntry, EpisodeHistoryEntry from mygpo.publisher.models import PublishedPodcast -from mygpo.subscriptions.models import Subscription, PodcastConfig +from mygpo.subscriptions.models import Subscription import logging logger = logging.getLogger(__name__) diff --git a/mygpo/settings.py b/mygpo/settings.py index bff3bf6c..d9564fb7 100644 --- a/mygpo/settings.py +++ b/mygpo/settings.py @@ -101,6 +101,7 @@ INSTALLED_APPS = ( 'mygpo.subscriptions', 'mygpo.history', 'mygpo.favorites', + 'mygpo.usersettings', 'mygpo.data', 'mygpo.userfeeds', 'mygpo.suggestions', diff --git a/mygpo/share/userpage.py b/mygpo/share/userpage.py index ef05e97e..e1d9b8ee 100644 --- a/mygpo/share/userpage.py +++ b/mygpo/share/userpage.py @@ -32,7 +32,7 @@ class UserpageView(View): context = { 'page_user': user, - 'flattr_username': user.profile.get_wksetting(FLATTR_USERNAME), + 'flattr_username': user.profile.settings.get_wksetting(FLATTR_USERNAME), 'site': site.domain, 'subscriptions_token': user.profile.get_token('subscriptions_token'), 'favorite_feeds_token': user.profile.get_token('favorite_feeds_token'), diff --git a/mygpo/subscriptions/__init__.py b/mygpo/subscriptions/__init__.py index 59a6807d..f656f9d4 100644 --- a/mygpo/subscriptions/__init__.py +++ b/mygpo/subscriptions/__init__.py @@ -4,8 +4,7 @@ import collections from django.db import transaction from mygpo.users.models import Client -from mygpo.subscriptions.models import (Subscription, SubscribedPodcast, - PodcastConfig, ) +from mygpo.subscriptions.models import Subscription, SubscribedPodcast from mygpo.subscriptions.signals import subscription_changed from mygpo.history.models import HistoryEntry from mygpo.utils import to_maxlength @@ -166,7 +165,7 @@ def get_subscribed_podcasts(user, only_public=False): .order_by('podcast')\ .distinct('podcast')\ .select_related('podcast') - private = PodcastConfig.objects.get_private_podcasts(user) + private = UserSettings.objects.get_private_podcasts(user) podcasts = [] for subscription in subscriptions: @@ -209,7 +208,7 @@ def get_subscription_history(user, client=None, since=None, until=None, if public_only: logger.info('... only public') - private = PodcastConfig.objects.get_private_podcasts(user) + private = UserSettings.objects.get_private_podcasts(user) history = history.exclude(podcast__in=private) return history diff --git a/mygpo/subscriptions/admin.py b/mygpo/subscriptions/admin.py index cf569c71..e0696d29 100644 --- a/mygpo/subscriptions/admin.py +++ b/mygpo/subscriptions/admin.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.contrib import admin -from mygpo.subscriptions.models import Subscription, PodcastConfig +from mygpo.subscriptions.models import Subscription @admin.register(Subscription) @@ -16,15 +16,3 @@ class SubscriptionAdmin(admin.ModelAdmin): list_select_related = ('user', 'podcast', 'client', ) raw_id_fields = ('user', 'podcast', 'client', ) - - -@admin.register(PodcastConfig) -class PodcastConfigAdmin(admin.ModelAdmin): - - # configuration for the list view - list_display = ('user', 'podcast', ) - - # fetch the related objects for the fields in list_display - list_select_related = ('user', 'podcast', ) - - raw_id_fields = ('user', 'podcast', ) diff --git a/mygpo/subscriptions/migrations/0003_remove_podcastconfig.py b/mygpo/subscriptions/migrations/0003_remove_podcastconfig.py new file mode 100644 index 00000000..19cb2963 --- /dev/null +++ b/mygpo/subscriptions/migrations/0003_remove_podcastconfig.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscriptions', '0002_unique_constraint'), + # ensure that data migration has been executed before model is deleted + ('usersettings', '0002_move_existing'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='podcastconfig', + unique_together=None, + ), + migrations.RemoveField( + model_name='podcastconfig', + name='podcast', + ), + migrations.RemoveField( + model_name='podcastconfig', + name='user', + ), + migrations.DeleteModel( + name='PodcastConfig', + ), + ] diff --git a/mygpo/subscriptions/models.py b/mygpo/subscriptions/models.py index 3b4a2150..72a8a94c 100644 --- a/mygpo/subscriptions/models.py +++ b/mygpo/subscriptions/models.py @@ -5,7 +5,7 @@ import collections from django.db import models from django.conf import settings -from mygpo.core.models import UpdateInfoModel, DeleteableModel, SettingsModel +from mygpo.core.models import UpdateInfoModel, DeleteableModel from mygpo.users.models import Client from mygpo.users.settings import PUBLIC_SUB_PODCAST from mygpo.podcasts.models import Podcast @@ -64,40 +64,5 @@ class Subscription(DeleteableModel): user=self.user, podcast=self.podcast, client=self.client) -class PodcastConfigmanager(models.Manager): - """ Manager for PodcastConfig objects """ - - def get_private_podcasts(self, user): - """ Returns the podcasts that the user has marked as private """ - configs = PodcastConfig.objects.filter(user=user)\ - .select_related('podcast') - private = [] - - for cfg in configs: - if not cfg.get_wksetting(PUBLIC_SUB_PODCAST): - private.append(cfg.podcast) - - return private - - -class PodcastConfig(SettingsModel, UpdateInfoModel): - """ Settings for a podcast, independant of a device / subscription """ - - # the user for which the config is stored - user = models.ForeignKey(settings.AUTH_USER_MODEL, db_index=True, - on_delete=models.CASCADE) - - # the podcast for which the config is stored - podcast = models.ForeignKey(Podcast, db_index=True, - on_delete=models.PROTECT) - - class Meta: - unique_together = [ - ['user', 'podcast'], - ] - - objects = PodcastConfigmanager() - - SubscribedPodcast = collections.namedtuple('SubscribedPodcast', 'podcast public ref_url') diff --git a/mygpo/subscriptions/views.py b/mygpo/subscriptions/views.py index fa2f9ee7..99be21f9 100644 --- a/mygpo/subscriptions/views.py +++ b/mygpo/subscriptions/views.py @@ -12,7 +12,7 @@ from django.contrib.syndication.views import Feed from django.contrib.auth import get_user_model from mygpo.podcasts.models import Podcast -from mygpo.subscriptions.models import Subscription, PodcastConfig +from mygpo.subscriptions.models import Subscription from mygpo.users.settings import PUBLIC_SUB_PODCAST from mygpo.utils import unzip, skip_pairs from mygpo.api import simple diff --git a/mygpo/users/migrations/0012_remove_userprofile_settings.py b/mygpo/users/migrations/0012_remove_userprofile_settings.py new file mode 100644 index 00000000..6e43bfdf --- /dev/null +++ b/mygpo/users/migrations/0012_remove_userprofile_settings.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_syncgroup_blank'), + ('usersettings', '0002_move_existing'), + ] + + operations = [ + migrations.RemoveField( + model_name='userprofile', + name='settings', + ), + ] diff --git a/mygpo/users/models.py b/mygpo/users/models.py index 70defe01..7ebd2218 100644 --- a/mygpo/users/models.py +++ b/mygpo/users/models.py @@ -14,8 +14,9 @@ from django.contrib.auth.models import User as DjangoUser from django.utils.translation import ugettext_lazy as _ from django.conf import settings -from mygpo.core.models import (TwitterModel, UUIDModel, SettingsModel, +from mygpo.core.models import (TwitterModel, UUIDModel, GenericManager, DeleteableModel, ) +from mygpo.usersettings.models import UserSettings from mygpo.podcasts.models import Podcast, Episode from mygpo.utils import random_token @@ -114,7 +115,7 @@ class UserProxy(DjangoUser): yield group -class UserProfile(TwitterModel, SettingsModel): +class UserProfile(TwitterModel): """ Additional information stored for a User """ # the user to which this profile belongs @@ -168,6 +169,14 @@ class UserProfile(TwitterModel, SettingsModel): setattr(self, token_name, random_token()) + @property + def settings(self): + try: + return UserSettings.objects.get(user=self.user, content_type=None) + except UserSettings.DoesNotExist: + return UserSettings(user=self.user, content_type=None, + object_id=None) + class SyncGroup(models.Model): """ A group of Clients """ diff --git a/mygpo/usersettings/__init__.py b/mygpo/usersettings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mygpo/usersettings/admin.py b/mygpo/usersettings/admin.py new file mode 100644 index 00000000..6e158c52 --- /dev/null +++ b/mygpo/usersettings/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from mygpo.usersettings.models import UserSettings + + +@admin.register(UserSettings) +class PodcastConfigAdmin(admin.ModelAdmin): + + # configuration for the list view + list_display = ('user', 'content_object', ) + + # fetch the related objects for the fields in list_display + list_select_related = ('user', 'content_object', ) + + raw_id_fields = ('user', ) diff --git a/mygpo/usersettings/migrations/0001_initial.py b/mygpo/usersettings/migrations/0001_initial.py new file mode 100644 index 00000000..3a81694b --- /dev/null +++ b/mygpo/usersettings/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import uuidfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserSettings', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('settings', models.TextField(default='{}')), + ('object_id', uuidfield.fields.UUIDField(max_length=32, null=True, blank=True)), + ('content_type', models.ForeignKey(blank=True, to='contenttypes.ContentType', null=True)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='usersettings', + unique_together=set([('user', 'content_type', 'object_id')]), + ), + # PostgreSQL does not consider null values for unique constraints; + # UserSettings for Users have no content_object; the following ensures + # there can only be one such entry per user + migrations.RunSQL( + 'CREATE UNIQUE INDEX usersettings_unique_null ON usersettings_usersettings (user_id) WHERE content_type_id IS NULL;', + 'DROP INDEX usersettings_unique_null;' + ) + ] diff --git a/mygpo/usersettings/migrations/0002_move_existing.py b/mygpo/usersettings/migrations/0002_move_existing.py new file mode 100644 index 00000000..515e5dfd --- /dev/null +++ b/mygpo/usersettings/migrations/0002_move_existing.py @@ -0,0 +1,59 @@ +import json + +from django.db import migrations +from django.contrib.contenttypes.models import ContentType + + +def move_podcastsettings(apps, schema_editor): + + PodcastConfig = apps.get_model("subscriptions", "PodcastConfig") + UserSettings = apps.get_model("usersettings", "UserSettings") + + for cfg in PodcastConfig.objects.all(): + if not json.loads(cfg.settings): + continue + + setting, created = UserSettings.objects.update_or_create( + user=cfg.user, + # we can't get the contenttype from cfg.podcast as it would be a + # different model + content_type=ContentType.objects.filter(app_label='podcasts', + model='podcast'), + object_id=cfg.podcast.pk, + defaults={ + 'settings': cfg.settings, + } + ) + + +def move_usersettings(apps, schema_editor): + + UserProfile = apps.get_model("users", "UserProfile") + UserSettings = apps.get_model("usersettings", "UserSettings") + + for profile in UserProfile.objects.all(): + if not json.loads(profile.settings): + continue + + setting, created = UserSettings.objects.update_or_create( + user=profile.user, + content_type=None, + object_id=None, + defaults={ + 'settings': profile.settings, + } + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('usersettings', '0001_initial'), + ('subscriptions', '0002_unique_constraint'), + ('users', '0011_syncgroup_blank'), + ] + + operations = [ + migrations.RunPython(move_podcastsettings), + migrations.RunPython(move_usersettings), + ] diff --git a/mygpo/usersettings/migrations/0003_meta_verbose_name.py b/mygpo/usersettings/migrations/0003_meta_verbose_name.py new file mode 100644 index 00000000..e964a553 --- /dev/null +++ b/mygpo/usersettings/migrations/0003_meta_verbose_name.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('usersettings', '0002_move_existing'), + ] + + operations = [ + migrations.AlterModelOptions( + name='usersettings', + options={'verbose_name': 'User Settings', 'verbose_name_plural': 'User Settings'}, + ), + ] diff --git a/mygpo/usersettings/migrations/__init__.py b/mygpo/usersettings/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mygpo/usersettings/models.py b/mygpo/usersettings/models.py new file mode 100644 index 00000000..b84f29e8 --- /dev/null +++ b/mygpo/usersettings/models.py @@ -0,0 +1,123 @@ +import json + +from django.db import models +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes import generic + +from uuidfield import UUIDField + +from mygpo.podcasts.models import Podcast + +import logging +logger = logging.getLogger(__name__) + + +class UserSettingsManager(models.Manager): + """ Manager for PodcastConfig objects """ + + def get_private_podcasts(self, user): + """ Returns the podcasts that the user has marked as private """ + settings = self.objects.filter( + user=user, + content_type=ContentType.objects.get_for_model(Podcast), + ) + + private = [] + for setting in settings: + if not setting.get_wksetting(PUBLIC_SUB_PODCAST): + private.append(cfg.podcast) + + return private + + def get_for_scope(self, user, scope): + """ Returns the settings object for the given user and scope obj + + If scope is None, the settings for the user are returned """ + if scope is None: + content_type = None + object_id = None + else: + content_type = ContentType.objects.get_for_model(scope) + object_id = scope.pk + + try: + return UserSettings.objects.get( + user=user, + content_type=content_type, + object_id=object_id, + ) + + except UserSettings.DoesNotExist: + # if it does not exist, return a new instance. It is up to the + # caller to save the object if required + return UserSettings( + user=user, + content_type=content_type, + object_id=object_id, + ) + + +class UserSettings(models.Model): + """ Stores settings for a podcast, episode, user or client """ + + # the user for which the config is stored + user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE) + + # see https://docs.djangoproject.com/en/1.6/ref/contrib/contenttypes/#generic-relations + content_type = models.ForeignKey(ContentType, null=True, blank=True) + object_id = UUIDField(null=True, blank=True) + content_object = generic.GenericForeignKey('content_type', 'object_id') + + settings = models.TextField(null=False, default='{}') + + class Meta: + unique_together = [ + ['user', 'content_type', 'object_id'], + ] + + verbose_name_plural = 'User Settings' + verbose_name = 'User Settings' + + objects = UserSettingsManager() + + def get_wksetting(self, setting): + """ returns the value of a well-known setting """ + try: + settings = json.loads(self.settings) + except ValueError as ex: + logger.warn('Decoding settings failed: {msg}'.format(msg=str(ex))) + return None + + return settings.get(setting.name, setting.default) + + def set_wksetting(self, setting, value): + try: + settings = json.loads(self.settings) + except ValueError as ex: + logger.warn('Decoding settings failed: {msg}'.format(msg=str(ex))) + settings = {} + settings[setting.name] = value + self.settings = json.dumps(settings) + + def get_setting(self, name, default): + settings = json.loads(self.settings) + return settings.get(name, default) + + def set_setting(self, name, value): + settings = json.loads(self.settings) + settings[name] = value + self.settings = json.dumps(settings) + + def del_setting(self, name): + settings = json.loads(self.settings) + try: + settings.pop(name) + except KeyError: + pass + self.settings = json.dumps(settings) + + def as_dict(self): + return json.loads(self.settings) diff --git a/mygpo/usersettings/tests.py b/mygpo/usersettings/tests.py new file mode 100644 index 00000000..11e9d2dd --- /dev/null +++ b/mygpo/usersettings/tests.py @@ -0,0 +1,95 @@ +from __future__ import unicode_literals + +import uuid +import urllib +import json + +from django.core.urlresolvers import reverse +from django.test.client import Client as TestClient +from django.test import TestCase + +from mygpo.test import create_auth_string, create_user +from mygpo.api.advanced import settings as views +from mygpo.usersettings.models import UserSettings +from mygpo.podcasts.models import Podcast, Episode +from mygpo.users.models import Client + + +class TestAPI(TestCase): + + def setUp(self): + self.user, pwd = create_user() + self.podcast_url = 'http://example.com/podcast.rss' + self.episode_url = 'http://example.com/podcast/episode-1.mp3' + self.uid = 'client-uid' + self.podcast = Podcast.objects.get_or_create_for_url(self.podcast_url) + self.episode = Episode.objects.get_or_create_for_url( + self.podcast, + self.episode_url, + ) + self.user_client = Client.objects.create( + id = uuid.uuid1(), + user = self.user, + uid = self.uid, + ) + self.client = TestClient() + self.extra = { + 'HTTP_AUTHORIZATION': create_auth_string(self.user.username, pwd) + } + + def tearDown(self): + self.user.delete() + self.episode.delete() + self.podcast.delete() + + def test_user_settings(self): + """ Create, update and verify settings for the user """ + url = self.get_url(self.user.username, 'account') + self._do_test_url(url) + + def test_podcast_settings(self): + url = self.get_url(self.user.username, 'podcast', { + 'podcast': self.podcast_url, + }) + self._do_test_url(url) + + def test_episode_settings(self): + url = self.get_url(self.user.username, 'episode', { + 'podcast': self.podcast_url, + 'episode': self.episode_url, + }) + self._do_test_url(url) + + def test_client_settings(self): + url = self.get_url(self.user.username, 'device', { + 'device': self.uid, + }) + self._do_test_url(url) + + def _do_test_url(self, url): + # set settings + settings = {'set': {'a': 'b', 'c': 'd'}} + resp = self.client.post(url, json.dumps(settings), + content_type='application/octet-stream', + **self.extra) + self.assertEqual(resp.status_code, 200, resp.content) + + # update settings + settings = {'set': {'a': 'x'}, 'remove': ['c']} + resp = self.client.post(url, json.dumps(settings), + content_type='application/octet-stream', + **self.extra) + self.assertEqual(resp.status_code, 200, resp.content) + + # get settings + resp = self.client.get(url, **self.extra) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(json.loads(resp.content), {'a': 'x'}) + + def get_url(self, username, scope, params={}): + url = reverse('settings-api', kwargs={ + 'username': username, + 'scope': scope, + }) + return '{url}?{params}'.format(url=url, + params=urllib.urlencode(params)) diff --git a/mygpo/usersettings/views.py b/mygpo/usersettings/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/mygpo/usersettings/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/mygpo/web/views/__init__.py b/mygpo/web/views/__init__.py index 97ba44f2..c59f427a 100644 --- a/mygpo/web/views/__init__.py +++ b/mygpo/web/views/__init__.py @@ -105,10 +105,10 @@ def dashboard(request, episode_count=10): if PublishedPodcast.objects.filter(publisher=request.user).exists(): checklist.append('publish') - if request.user.profile.get_wksetting(FLATTR_TOKEN): + if request.user.profile.settings.get_wksetting(FLATTR_TOKEN): checklist.append('flattr') - if request.user.profile.get_wksetting(FLATTR_AUTO): + if request.user.profile.settings.get_wksetting(FLATTR_AUTO): checklist.append('auto-flattr') tomorrow = datetime.today() + timedelta(days=1) diff --git a/mygpo/web/views/episode.py b/mygpo/web/views/episode.py index 221eece3..404996b1 100644 --- a/mygpo/web/views/episode.py +++ b/mygpo/web/views/episode.py @@ -73,7 +73,7 @@ def episode(request, episode): played_parts = EpisodeHeatmap(podcast, episode, user, episode.duration) devices = {c.id.hex: c for c in user.client_set.all()} - can_flattr = user.profile.get_wksetting(FLATTR_TOKEN) and episode.flattr_url + can_flattr = user.profile.settings.get_wksetting(FLATTR_TOKEN) and episode.flattr_url else: has_history = False diff --git a/mygpo/web/views/podcast.py b/mygpo/web/views/podcast.py index 8706f4e1..045a49d9 100644 --- a/mygpo/web/views/podcast.py +++ b/mygpo/web/views/podcast.py @@ -28,6 +28,7 @@ from mygpo.core.tasks import flattr_thing from mygpo.utils import normalize_feed_url from mygpo.users.settings import PUBLIC_SUB_PODCAST, FLATTR_TOKEN from mygpo.publisher.utils import check_publisher_permission +from mygpo.usersettings.models import UserSettings from mygpo.users.models import Client from mygpo.web.forms import SyncForm from mygpo.decorators import allowed_methods @@ -78,7 +79,7 @@ def show(request, podcast): has_history = HistoryEntry.objects.filter(user=user, podcast=podcast)\ .exists() - can_flattr = (user.profile.get_wksetting(FLATTR_TOKEN) and + can_flattr = (user.profile.settings.get_wksetting(FLATTR_TOKEN) and podcast.flattr_url) else: @@ -327,12 +328,13 @@ def subscribe_url(request): @never_cache @allowed_methods(['POST']) def set_public(request, podcast, public): - config, created = PodcastConfig.objects.get_or_create( + settings, created = UserSettings.objects.get_or_create( user=request.user, - podcast=podcast, + content_type=ContentType.objects.get_for_model(podcast), + object_id=podcast.pk, ) - config.set_wksetting(PUBLIC_SUB_PODCAST, public) - config.save() + settings.set_wksetting(PUBLIC_SUB_PODCAST, public) + settings.save() return HttpResponseRedirect(get_podcast_link_target(podcast)) diff --git a/mygpo/web/views/settings.py b/mygpo/web/views/settings.py index e1c7977f..920d99ec 100644 --- a/mygpo/web/views/settings.py +++ b/mygpo/web/views/settings.py @@ -31,7 +31,7 @@ from django.views.generic.base import View from django.utils.html import strip_tags from mygpo.podcasts.models import Podcast -from mygpo.subscriptions.models import PodcastConfig +from mygpo.usersettings.models import UserSettings from mygpo.decorators import allowed_methods from mygpo.web.forms import UserAccountForm, ProfileForm, FlattrForm from mygpo.web.utils import normalize_twitter @@ -59,14 +59,14 @@ def account(request): form = UserAccountForm({ 'email': request.user.email, - 'public': request.user.profile.get_wksetting(PUBLIC_SUB_USER) + 'public': request.user.profile.settings.get_wksetting(PUBLIC_SUB_USER) }) flattr_form = FlattrForm({ - 'enable': request.user.profile.get_wksetting(FLATTR_AUTO), - 'token': request.user.profile.get_wksetting(FLATTR_TOKEN), - 'flattr_mygpo': request.user.profile.get_wksetting(FLATTR_MYGPO), - 'username': request.user.profile.get_wksetting(FLATTR_USERNAME), + 'enable': request.user.profile.settings.get_wksetting(FLATTR_AUTO), + 'token': request.user.profile.settings.get_wksetting(FLATTR_TOKEN), + 'flattr_mygpo': request.user.profile.settings.get_wksetting(FLATTR_MYGPO), + 'username': request.user.profile.settings.get_wksetting(FLATTR_USERNAME), }) return render(request, 'account.html', { @@ -143,10 +143,11 @@ class FlattrSettingsView(View): flattr_mygpo = form.cleaned_data.get('flattr_mygpo', False) username = form.cleaned_data.get('username', '') - user.profile.set_wksetting(FLATTR_AUTO, auto_flattr) - user.profile.set_wksetting(FLATTR_MYGPO, flattr_mygpo) - user.profile.set_wksetting(FLATTR_USERNAME, username) - user.profile.save() + settings = user.profile.settings + settings.set_wksetting(FLATTR_AUTO, auto_flattr) + settings.set_wksetting(FLATTR_MYGPO, flattr_mygpo) + settings.set_wksetting(FLATTR_USERNAME, username) + settings.save() return HttpResponseRedirect(reverse('account') + '#flattr') @@ -156,10 +157,11 @@ class FlattrLogout(View): def get(self, request): user = request.user - user.profile.set_wksetting(FLATTR_AUTO, False) - user.profile.set_wksetting(FLATTR_TOKEN, False) - user.profile.set_wksetting(FLATTR_MYGPO, False) - user.profile.save() + settings = user.profile.settings + settings.set_wksetting(FLATTR_AUTO, False) + settings.set_wksetting(FLATTR_TOKEN, False) + settings.set_wksetting(FLATTR_MYGPO, False) + settings.save() return HttpResponseRedirect(reverse('account') + '#flattr') @@ -178,8 +180,9 @@ class FlattrTokenView(View): token = flattr.process_retrieved_code(url) if token: messages.success(request, _('Authentication successful')) - user.profile.set_wksetting(FLATTR_TOKEN, token) - user.profile.save() + settings = user.profile.settings + settings.set_wksetting(FLATTR_TOKEN, token) + settings.save() else: messages.error(request, _('Authentication failed. Try again later')) @@ -236,13 +239,14 @@ class PodcastPrivacySettings(View): def post(self, request, podcast_id): podcast = Podcast.objects.get(id=podcast_id) - config, created = PodcastConfig.objects.get_or_create( + settings, created = UserSettings.objects.get_or_create( user=request.user, - podcast=podcast, + content_type=ContentType.objects.get_for_model(podcast), + object_id=podcast.pk, ) - config.set_wksetting(PUBLIC_SUB_PODCAST, self.public) - config.save() + settings.set_wksetting(PUBLIC_SUB_PODCAST, self.public) + settings.save() return HttpResponseRedirect(reverse('privacy')) @@ -254,7 +258,7 @@ def privacy(request): podcasts = Podcast.objects.filter(subscription__user=user)\ .distinct('pk') - private = PodcastConfig.objects.get_private_podcasts(user) + private = UserSettings.objects.get_private_podcasts(user) subscriptions = [] for podcast in podcasts: @@ -262,7 +266,7 @@ def privacy(request): subscriptions.append( (podcast, podcast in private) ) return render(request, 'privacy.html', { - 'private_subscriptions': not request.user.profile.get_wksetting(PUBLIC_SUB_USER), + 'private_subscriptions': not request.user.profile.settings.get_wksetting(PUBLIC_SUB_USER), 'subscriptions': subscriptions, 'domain': site.domain, }) -- 2.11.4.GIT