diff --git a/README.md b/README.md index 08d9f75..a8a5e7c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Platform to manage radio programs, schedules, cms, etc. -- main test repo # Applications * **programs**: programs, episodes, schedules, sounds and tracks; -* **streams**: streams and diffusions, links with LiquidSoap; -* **website**: website rendering, using models defined by the previous apps; +* **cms**: cms renderer +* **website**: the website using the cms and the programs # Code and names conventions and uses diff --git a/aircox_cms/models.py b/aircox_cms/models.py index b690bde..820809f 100644 --- a/aircox_cms/models.py +++ b/aircox_cms/models.py @@ -12,57 +12,18 @@ from django.dispatch import receiver from taggit.managers import TaggableManager -class Thread (models.Model): - """ - Object assigned to any Post and children that can be used to have parent and - children relationship between posts of different kind. - - We use this system instead of having directly a GenericForeignKey into the - Post because it avoids having to define the relationship with two models for - routing (one for the parent and one for the children). - """ - post_type = models.ForeignKey(ContentType) - post_id = models.PositiveIntegerField() - post = GenericForeignKey('post_type', 'post_id') - - __initial_post = None - - @classmethod - def __get_query_set (cl, function, model, post, kwargs): - if post: - model = type(post) - kwargs['post_id'] = post.id - - kwargs['post_type'] = ContentType.objects.get_for_model(model) - return getattr(cl.objects, function)(**kwargs) - - @classmethod - def get (cl, model = None, post = None, **kwargs): - return cl.__get_query_set('get', model, post, kwargs) - - @classmethod - def filter (cl, model = None, post = None, **kwargs): - return self.__get_query_set('filter', model, post, kwargs) - - @classmethod - def exclude (cl, model = None, post = None, **kwargs): - return self.__get_query_set('exclude', model, post, kwargs) - - def save (self, *args, **kwargs): - self.post = self.__initial_post or self.post - super().save(*args, **kwargs) - - def __str__ (self): - return self.post_type.name + ': ' + str(self.post) - class Post (models.Model): - thread = models.ForeignKey( - Thread, + thread_type = models.ForeignKey( + ContentType, on_delete=models.SET_NULL, - blank = True, null = True, - help_text = _('the publication is posted on this thread'), + blank = True, null = True ) + thread_pk = models.PositiveIntegerField( + blank = True, null = True + ) + thread = GenericForeignKey('thread_type', 'thread_pk') + author = models.ForeignKey( User, verbose_name = _('author'), @@ -94,7 +55,7 @@ class Post (models.Model): ) def detail_url (self): - return reverse(self._meta.verbose_name_plural.lower() + '_detail', + return reverse(self._meta.verbose_name.lower() + '_detail', kwargs = { 'pk': self.pk, 'slug': slugify(self.title) }) @@ -151,7 +112,6 @@ class RelatedPost (Post, metaclass = RelatedPostBase): mapping = None # dict of related mapping values bind_mapping = False # update fields of related data on save - def get_attribute (self, attr): attr = self._relation.mappings.get(attr) return self.related.__dict__[attr] if attr else None @@ -163,32 +123,10 @@ class RelatedPost (Post, metaclass = RelatedPostBase): if self._relation.bind_mapping: self.related.__dict__.update({ rel_attr: self.__dict__[attr] - for attr, rel_attr in self.Relation.mapping + for attr, rel_attr in self.Relation.mapping.items() }) - self.related.save() super().save(*args, **kwargs) -@receiver(post_init) -def on_thread_init (sender, instance, **kwargs): - if not issubclass(Thread, sender): - return - instance.__initial_post = instance.post - -@receiver(post_save) -def on_post_save (sender, instance, created, *args, **kwargs): - if not issubclass(sender, Post) or not created: - return - - thread = Thread(post = instance) - thread.save() - -@receiver(post_delete) -def on_post_delete (sender, instance, using, *args, **kwargs): - try: - Thread.get(sender, post = instance).delete() - except: - pass - diff --git a/aircox_cms/requirements.txt b/aircox_cms/requirements.txt new file mode 100644 index 0000000..1c94d31 --- /dev/null +++ b/aircox_cms/requirements.txt @@ -0,0 +1,4 @@ +Django>=1.9.0 +django-taggit>=0.12.1 +easy_thumbnails + diff --git a/aircox_cms/routes.py b/aircox_cms/routes.py index e41af0c..c31f2d3 100644 --- a/aircox_cms/routes.py +++ b/aircox_cms/routes.py @@ -1,4 +1,5 @@ from django.conf.urls import url +from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django.utils.translation import ugettext as _, ugettext_lazy @@ -27,9 +28,9 @@ class Route: """ Base class for routing. Given a model, we generate url specific for each route type. The generated url takes this form: - base_name + '/' + route_name + '/' + '/'.join(route_url_args) + model_name + '/' + route_name + '/' + '/'.join(route_url_args) - Where base_name by default is the given model's verbose_name (uses plural if + Where model_name by default is the given model's verbose_name (uses plural if Route is for a list). The given view is considered as a django class view, and has view_ @@ -38,14 +39,14 @@ class Route: url_args = [] # arguments passed from the url [ (name : regex),... ] @classmethod - def get_queryset (cl, model, request, **kwargs): + def get_queryset (cl, website, model, request, **kwargs): """ Called by the view to get the queryset when it is needed """ pass @classmethod - def get_object (cl, model, request, **kwargs): + def get_object (cl, website, model, request, **kwargs): """ Called by the view to get the object when it is needed """ @@ -56,10 +57,8 @@ class Route: return '' @classmethod - def as_url (cl, model, view, view_kwargs = None): - base_name = model._meta.verbose_name_plural.lower() - - pattern = '^{}/{}'.format(base_name, cl.name) + def as_url (cl, model, model_name, view, view_kwargs = None): + pattern = '^{}/{}'.format(model_name, cl.name) if cl.url_args: url_args = '/'.join([ '(?P<{}>{}){}'.format( @@ -78,7 +77,7 @@ class Route: kwargs.update(view_kwargs) return url(pattern, view, kwargs = kwargs, - name = base_name + '_' + cl.name) + name = model_name + '_' + cl.name) class DetailRoute (Route): @@ -89,7 +88,7 @@ class DetailRoute (Route): ] @classmethod - def get_object (cl, model, request, pk, **kwargs): + def get_object (cl, website, model, request, pk, **kwargs): return model.objects.get(pk = int(pk)) @@ -97,7 +96,7 @@ class AllRoute (Route): name = 'all' @classmethod - def get_queryset (cl, model, request, **kwargs): + def get_queryset (cl, website, model, request, **kwargs): return model.objects.all() @classmethod @@ -108,14 +107,32 @@ class AllRoute (Route): class ThreadRoute (Route): + """ + Select posts using by their assigned thread. + + - "thread_model" can be a string with the name of a registered item on + website or a model. + - "pk" is the pk of the thread item. + """ name = 'thread' url_args = [ + ('thread_model', '(\w|_|-)+'), ('pk', '[0-9]+'), ] @classmethod - def get_queryset (cl, model, request, pk, **kwargs): - return model.objects.filter(thread__pk = int(pk)) + def get_queryset (cl, website, model, request, thread_model, pk, **kwargs): + if type(thread_model) is str: + thread_model = website.registry.get(thread_model).model + + if not thread_model: + return + + thread_model = ContentType.objects.get_for_model(thread_model) + return model.objects.filter( + thread_type = thread_model, + thread_pk = int(pk) + ) class DateRoute (Route): @@ -127,7 +144,7 @@ class DateRoute (Route): ] @classmethod - def get_queryset (cl, model, request, year, month, day, **kwargs): + def get_queryset (cl, website, model, request, year, month, day, **kwargs): return model.objects.filter( date__year = int(year), date__month = int(month), @@ -139,7 +156,7 @@ class SearchRoute (Route): name = 'search' @classmethod - def get_queryset (cl, model, request, **kwargs): + def get_queryset (cl, website, model, request, **kwargs): q = request.GET.get('q') or '' qs = model.objects for search_field in model.search_fields or []: @@ -151,4 +168,3 @@ class SearchRoute (Route): return qs - diff --git a/aircox_cms/static/aircox_cms/styles.css b/aircox_cms/static/aircox_cms/styles.css index db2fe27..c3d9790 100644 --- a/aircox_cms/static/aircox_cms/styles.css +++ b/aircox_cms/static/aircox_cms/styles.css @@ -20,9 +20,13 @@ body { } +.section { + vertical-align: top; +} + main .section { - width: calc(50% - 1em); - float: left; + width: calc(50% - 2em); + display: inline-block; padding: 0.5em; } @@ -39,3 +43,8 @@ main .section { } +.post_item { + display: block; +} + + diff --git a/aircox_cms/templates/aircox_cms/list.html b/aircox_cms/templates/aircox_cms/list.html index de33a17..6dfec85 100644 --- a/aircox_cms/templates/aircox_cms/list.html +++ b/aircox_cms/templates/aircox_cms/list.html @@ -12,23 +12,23 @@ {% endif %} {% if 'image' in view.fields %} - + {% endif %} {% if 'title' in view.fields %} -

{{ post.title }}

+

{{ post.title }}

{% endif %} {% if 'content' in view.fields %} diff --git a/aircox_cms/templates/aircox_cms/section_list.html b/aircox_cms/templates/aircox_cms/section_list.html index 46479af..35589ee 100644 --- a/aircox_cms/templates/aircox_cms/section_list.html +++ b/aircox_cms/templates/aircox_cms/section_list.html @@ -6,6 +6,9 @@ diff --git a/aircox_cms/views.py b/aircox_cms/views.py index 30f681a..c579d8d 100644 --- a/aircox_cms/views.py +++ b/aircox_cms/views.py @@ -1,6 +1,5 @@ import re - from django.templatetags.static import static from django.shortcuts import render from django.template.loader import render_to_string @@ -19,7 +18,7 @@ class PostBaseView: embed = False # page is embed (if True, only post content is printed classes = '' # extra classes for the content - def get_base_context (self): + def get_base_context (self, **kwargs): """ Return a context with all attributes of this classe plus 'view' set to self. @@ -32,7 +31,7 @@ class PostBaseView: if not self.embed: context['menus'] = { - k: v.get(self.request) + k: v.get(self.request, **kwargs) for k, v in { k: self.website.get_menu(k) for k in self.website.menu_layouts @@ -70,10 +69,12 @@ class PostListView (PostBaseView, ListView): template_name = 'aircox_cms/list.html' allow_empty = True + model = None route = None query = None fields = [ 'date', 'time', 'image', 'title', 'content' ] + icon_size = '64x64' def __init__ (self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -84,7 +85,11 @@ class PostListView (PostBaseView, ListView): return super().dispatch(request, *args, **kwargs) def get_queryset (self): - qs = self.route.get_queryset(self.model, self.request, **self.kwargs) + if self.route: + qs = self.route.get_queryset(self.website, self.model, self.request, + **self.kwargs) + else: + qs = self.queryset or self.model.objects.all() query = self.query query.update(self.request.GET) @@ -156,7 +161,7 @@ class PostDetailView (DetailView, PostBaseView): context.update(self.get_base_context()) context.update({ 'sections': [ - section.get(self.request, object = self.object) + section.get(self.request, **kwargs) for section in self.sections ] }) @@ -184,7 +189,7 @@ class Menu (View): 'classes': self.classes, 'position': self.position, 'sections': [ - section.get(self.request, object = None) + section.get(self.request, object = None, **kwargs) for section in self.sections ] } @@ -202,36 +207,48 @@ class BaseSection (View): in order to have extra content about a post, or in menus. """ template_name = 'aircox_cms/base_section.html' - tag = 'div' # container tags + kwargs = None # kwargs argument passed to get + tag = 'div' # container tags classes = '' # container classes attrs = '' # container extra attributes content = '' # content + visible = True # if false renders an empty string - def get_context_data (self, **kwargs): + def get_context_data (self): return { + 'view': self, 'tag': self.tag, 'classes': self.classes, 'attrs': self.attrs, + 'visible': self.visible, 'content': self.content, } def get (self, request, **kwargs): self.request = request - context = self.get_context_data(**kwargs) - return render_to_string(self.template_name, context) + self.kwargs = kwargs + + context = self.get_context_data() + # get_context_data may call extra function that can change visibility + if self.visible: + return render_to_string(self.template_name, context) + return '' class Section (BaseSection): + """ + A Section that can be related to an object. + """ template_name = 'aircox_cms/section.html' - require_object = False object = None + object_required = False title = '' header = '' bottom = '' - def get_context_data (self, **kwargs): - context = super().get_context_data(**kwargs) + def get_context_data (self): + context = super().get_context_data() context.update({ 'title': self.title, 'header': self.header, @@ -239,14 +256,20 @@ class Section (BaseSection): }) return context - def get (self, request, **kwargs): - self.object = kwargs.get('object') or self.object + def get (self, request, object = None, **kwargs): + self.object = object or self.object + if self.object_required and not self.object: + raise ValueError('object is required by this Section but not given') + return super().get(request, **kwargs) class Sections: class Image (BaseSection): - url = None # relative url to the image + """ + Render an image with the given relative url. + """ + url = None @property def content (self): @@ -256,6 +279,10 @@ class Sections: class PostContent (Section): + """ + Render the content of the Post (format the text a bit and escape HTML + tags). + """ @property def content (self): content = escape(self.object.content) @@ -265,6 +292,9 @@ class Sections: class PostImage (Section): + """ + Render the image of the Post + """ @property def content (self): return ''.format( @@ -281,59 +311,78 @@ class Sections: icon = None title = None text = None + url = None - def __init__ (self, icon, title = None, text = None): + def __init__ (self, icon, title = None, text = None, url = None): self.icon = icon self.title = title self.text = text - use_icons = True - icon_size = '32x32' + hide_empty = False # hides the section if the list is empty + use_icons = True # print icons + icon_size = '32x32' # icons size template_name = 'aircox_cms/section_list.html' def get_object_list (self): return [] def get_context_data (self, **kwargs): + object_list = self.get_object_list() + self.visibility = True + if not object_list and hide_empty: + self.visibility = False + context = super().get_context_data(**kwargs) context.update({ 'classes': context.get('classes') + ' section_list', 'icon_size': self.icon_size, - 'object_list': self.get_object_list(), + 'object_list': object_list, }) return context - class UrlList (List): + class Urls (List): + """ + Render a list of urls of targets that are Posts + """ classes = 'section_urls' targets = None - def get_object_list (self, request, **kwargs): + def get_object_list (self): return [ List.Item( target.image or None, - '{}'.format(target.detail_url(), target.title) + target.title, + url = target.detail_url(), ) for target in self.targets ] - - class PostList (PostListView): - route = None - model = None + class Posts (PostBaseView, Section): + """ + Render a list using PostListView's template. + """ embed = True + icon_size = '64x64' + fields = [ 'date', 'time', 'image', 'title', 'content' ] - def __init__ (self, *args, **kwargs): - super().__init__(*args, **kwargs) + def get_object_list (self): + return [] - def get_kwargs (self, request, **kwargs): - return kwargs - - def dispatch (self, request, *args, **kwargs): - kwargs = self.get_kwargs(kwargs) - response = super().dispatch(request, *args, **kwargs) - return str(response.content) + def render_list (self): + self.embed = True + context = self.get_base_context(**self.kwargs) + context.update({ + 'object_list': self.get_object_list(), + 'embed': True, + }) + print(context['object_list'][0].image) + return render_to_string(PostListView.template_name, context) + def get_context_data (self, **kwargs): + context = super().get_context_data(**kwargs) + context['content'] = self.render_list() + return context class ViewSet: @@ -348,15 +397,25 @@ class ViewSet: detail_view = PostDetailView detail_sections = [ - Sections.PostContent, - Sections.PostImage, + Sections.PostContent(), + Sections.PostImage(), ] - def __init__ (self, website = None): - self.detail_sections = [ - section() - for section in self.detail_sections - ] + + def get_list_name (self): + """ + Return a string with the name to use in the route for the lists + """ + return self.model._meta.verbose_name_plural.lower() + + @classmethod + def get_detail_name (cl): + """ + Return a string with the name to use in the route for the details + """ + return cl.model._meta.verbose_name.lower() + + def connect (self, website = None): self.detail_view = self.detail_view.as_view( model = self.model, sections = self.detail_sections, @@ -367,8 +426,9 @@ class ViewSet: website = website, ) - self.urls = [ route.as_url(self.model, self.list_view) - for route in self.list_routes ] + \ - [ routes.DetailRoute.as_url(self.model, self.detail_view ) ] + self.urls = [ route.as_url(self.model, self.get_list_name(), + self.list_view) for route in self.list_routes ] + \ + [ routes.DetailRoute.as_url(self.model, + self.get_detail_name(), self.detail_view ) ] diff --git a/aircox_cms/website.py b/aircox_cms/website.py index 45a4a6c..dd35c70 100644 --- a/aircox_cms/website.py +++ b/aircox_cms/website.py @@ -12,6 +12,7 @@ class Website: 'right', 'bottom', 'header', 'footer'] router = None + registry = {} @property def urls (self): @@ -27,7 +28,9 @@ class Website: Register a ViewSet (or subclass) to the router, and connect it to self. """ - view_set = view_set(website = self) + view_set = view_set() + view_set.connect(website = self) + self.registry[view_set.get_detail_name()] = view_set self.router.register_set(view_set) def get_menu (self, position): diff --git a/aircox_programs/admin.py b/aircox_programs/admin.py index 65892fd..54d67dd 100755 --- a/aircox_programs/admin.py +++ b/aircox_programs/admin.py @@ -71,7 +71,7 @@ class ProgramAdmin (NameableAdmin): @admin.register(Episode) class EpisodeAdmin (NameableAdmin): list_filter = ['program'] + NameableAdmin.list_filter - fields = NameableAdmin.fields + ['sounds'] + fields = NameableAdmin.fields + ['sounds', 'program'] inlines = (TrackInline, DiffusionInline) diff --git a/website/admin.py b/website/admin.py index b0f42b0..cad2fc2 100644 --- a/website/admin.py +++ b/website/admin.py @@ -20,7 +20,7 @@ def add_inline (base_model, post_model, prepend = False): 'fields': ['title', 'content', 'image', 'tags'] }), (None, { - 'fields': ['date', 'published', 'author', 'thread'] + 'fields': ['date', 'published', 'author', 'thread_pk', 'thread_type'] }) ] @@ -40,6 +40,8 @@ def add_inline (base_model, post_model, prepend = False): add_inline(programs.Program, Program, True) add_inline(programs.Episode, Episode, True) +admin.site.register(Program) +admin.site.register(Episode) #class ArticleAdmin (DescriptionAdmin): # fieldsets = copy.deepcopy(DescriptionAdmin.fieldsets) diff --git a/website/models.py b/website/models.py index 56428ea..1551a04 100644 --- a/website/models.py +++ b/website/models.py @@ -6,6 +6,7 @@ import aircox_programs.models as programs class Program (RelatedPost): class Relation: related_model = programs.Program + bind_mapping = True mapping = { 'title': 'name', 'content': 'description', @@ -14,6 +15,7 @@ class Program (RelatedPost): class Episode (RelatedPost): class Relation: related_model = programs.Episode + bind_mapping = True mapping = { 'title': 'name', 'content': 'description', diff --git a/website/static/website/styles.css b/website/static/website/styles.css index 7117ce0..cc758a0 100644 --- a/website/static/website/styles.css +++ b/website/static/website/styles.css @@ -9,36 +9,87 @@ h1, h2, h3 { font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif } +time { + font-size: 0.9em; + color: #616161; +} + +a { + text-decoration: none; + color: #616161; +} + +a:hover { + color: #818181; +} + nav.menu { padding: 0.5em; } -nav.menu_top { - background-color: #212121; - color: #007EDF; - font-size: 1.1em; -} + nav.menu_top { + background-color: #212121; + color: #007EDF; + font-size: 1.1em; + box-shadow: 0em 0.2em 0.5em 0.1em black + } - header { + header.menu { + padding: 0.2em; height: 9em; } - header img { - height: 100%; - float: left; - } + header.menu img { + height: 100%; + float: left; + } - header .colony { - position: fixed; - top: 0em; - right: 0; - z-index: -1; - } + #colony img { + height: auto; + position: fixed; + top: 1em; + right: 0; + z-index: -1; + } .page { width: 100%; + padding: 1.5em 0em; background-color: rgba(255, 255, 255, 0.8); } + .page img { + box-shadow: 0em 0em 0.2em 0.01em black; + border-radius: 0.2em; + } + + +.post_list { + background-color: #F2F2F2; + box-shadow: inset 0.2em 0.2em 0.2em 0.01em black; +} + + .post_list .post_item { + min-height: 64px; + padding: 0.2em; + } + + .post_list .post_item:hover { + } + + .post_list h3 { + margin: 0.2em; + } + + .post_list time { + float: right; + } + + .post_list img { + float: left; + margin-right: 0.5em; + } + + diff --git a/website/urls.py b/website/urls.py index 8026014..71db994 100644 --- a/website/urls.py +++ b/website/urls.py @@ -18,7 +18,8 @@ class ProgramSet (ViewSet): ] detail_sections = ViewSet.detail_sections + [ - ScheduleSection, + ScheduleSection(), + EpisodesSection(), ] class EpisodeSet (ViewSet): @@ -49,7 +50,7 @@ website = Website( position = 'header', sections = [ Sections.Image(url = 'website/logo.png'), - Sections.Image(url = 'website/colony.png', classes='colony'), + Sections.Image(url = 'website/colony.png', attrs = { 'id': 'colony' }), ] ), diff --git a/website/views.py b/website/views.py index 9aa4676..b7acda8 100644 --- a/website/views.py +++ b/website/views.py @@ -8,6 +8,9 @@ from django.utils.translation import ugettext as _, ugettext_lazy import aircox_programs.models as programs from aircox_cms.views import Sections +from website.models import * + + class PlayListSection (Sections.List): title = _('Playlist') @@ -34,4 +37,10 @@ class ScheduleSection (Sections.List): ] +class EpisodesSection (Sections.Posts): + title = _('Episodes') + + def get_object_list (self): + return Episode.objects.filter(related__program = self.object.pk) +