Rafał Pitoń 7 лет назад
Родитель
Сommit
9266e8e1f0

+ 31 - 0
misago/legal/admin.py

@@ -0,0 +1,31 @@
+from django.conf.urls import url
+from django.utils.translation import ugettext_lazy as _
+
+from .views.admin import (
+    AgreementsList, DeleteAgreement, EditAgreement, NewAgreement, SetAgreementAsActive
+)
+
+
+class MisagoAdminExtension(object):
+    def register_urlpatterns(self, urlpatterns):
+        # Legal Agreements
+        urlpatterns.namespace(r'^agreements/', 'agreements', 'users')
+        urlpatterns.patterns(
+            'users:agreements',
+            url(r'^$', AgreementsList.as_view(), name='index'),
+            url(r'^(?P<page>\d+)/$', AgreementsList.as_view(), name='index'),
+            url(r'^new/$', NewAgreement.as_view(), name='new'),
+            url(r'^edit/(?P<pk>\d+)/$', EditAgreement.as_view(), name='edit'),
+            url(r'^delete/(?P<pk>\d+)/$', DeleteAgreement.as_view(), name='delete'),
+            url(r'^set-as-active/(?P<pk>\d+)/$', SetAgreementAsActive.as_view(), name='set-as-active'),
+        )
+        
+    def register_navigation_nodes(self, site):
+        site.add_node(
+            name=_("Agreements"),
+            icon='fa fa-check-square-o',
+            parent='misago:admin:users',
+            after='misago:admin:users:data-downloads:index',
+            namespace='misago:admin:users:agreements',
+            link='misago:admin:users:agreements:index',
+        )

+ 79 - 0
misago/legal/forms.py

@@ -0,0 +1,79 @@
+from django import forms
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
+
+from .models import Agreement
+from .utils import set_agreement_as_active
+
+
+class AgreementForm(forms.ModelForm):
+    type = forms.ChoiceField(label=_("Type"), choices=Agreement.TYPE_CHOICES)
+    title = forms.CharField(
+        label=_("Title"),
+        help_text=_("Optional, leave empty for agreement to be named after its type."),
+        required=False,
+    )
+    is_active = forms.BooleanField(
+        label=_("Set as active for its type"),
+        help_text=_(
+            "If other agreement is already active for this type, it will be unset and replaced "
+            "with this one. "
+            "Misago will ask users who didn't accept this agreement to do so before allowing them "
+            "to continue using the site's features."
+        ),
+        required=False,
+    )
+    link = forms.URLField(
+        label=_("Link"),
+        help_text=_("If your agreement is located on other page, enter here a link to it."),
+        required=False,
+    )
+    text = forms.CharField(
+        label=_("Text"),
+        help_text=_("You can use Markdown syntax for rich text elements."),
+        widget=forms.Textarea,
+        required=False,
+    )
+
+    class Meta:
+        model = Agreement
+        fields = ['type', 'title', 'link', 'text', 'is_active']
+
+    def clean(self):
+        data = super(AgreementForm, self).clean()
+
+        if not data.get('link') and not data.get('text'):
+            raise forms.ValidationError(_("Please fill in agreement link or text."))
+
+        return data
+
+    def save(self):
+        agreement = super(AgreementForm, self).save()
+        if agreement.is_active:
+            set_agreement_as_active(agreement)
+        Agreement.objects.invalidate_cache()
+        return agreement
+
+
+class SearchAgreementsForm(forms.Form):
+    type = forms.MultipleChoiceField(
+        label=_("Type"),
+        required=False,
+        choices=Agreement.TYPE_CHOICES,
+    )
+    content = forms.CharField(
+        label=_("Content"),
+        required=False,
+    )
+
+    def filter_queryset(self, search_criteria, queryset):
+        criteria = search_criteria
+        if criteria.get('type') is not None:
+            queryset = queryset.filter(type__in=criteria['type'])
+
+        if criteria.get('content'):
+            search_title = Q(title__icontains=criteria['content'])
+            search_text = Q(text__icontains=criteria['content'])
+            queryset = queryset.filter(search_title | search_text)
+
+        return queryset

+ 5 - 5
misago/legal/migrations/0002_agreement_useragreement.py

@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Generated by Django 1.11.14 on 2018-08-13 15:46
+# Generated by Django 1.11.15 on 2018-08-15 20:58
 from __future__ import unicode_literals
 
 from django.conf import settings
@@ -22,10 +22,10 @@ class Migration(migrations.Migration):
             name='Agreement',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('type', models.CharField(choices=[('terms-of-service', 'Terms of service'), ('privacy-policy', 'Privacy Policy')], db_index=True, default='terms-of-service', max_length=20)),
-                ('version', models.SlugField(blank=True, unique=True)),
-                ('title', models.CharField(max_length=255)),
-                ('text', models.TextField()),
+                ('type', models.CharField(choices=[('terms-of-service', 'Terms of service'), ('privacy-policy', 'Privacy policy')], db_index=True, default='terms-of-service', max_length=20)),
+                ('title', models.CharField(blank=True, max_length=255, null=True)),
+                ('link', models.URLField(blank=True, max_length=255, null=True)),
+                ('text', models.TextField(blank=True, null=True)),
                 ('is_active', models.BooleanField(default=False)),
                 ('created_on', models.DateTimeField(default=django.utils.timezone.now)),
                 ('created_by_name', models.CharField(blank=True, max_length=255, null=True)),

+ 49 - 8
misago/legal/models.py

@@ -1,18 +1,46 @@
-from django.core.exceptions import ValidationError
 from django.db import models
-from django.template.defaultfilters import slugify
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 
 from misago.conf import settings
+from misago.core.cache import cache
+
+
+CACHE_KEY = 'agreements'
+
+
+class AgreementManager(models.Manager):
+    def invalidate_cache(self):
+        cache.delete(CACHE_KEY)
+
+    def get_agreements(self):
+        agreements = self.get_agreements_from_cache()
+        if agreements == 'nada':
+            agreements = self.get_agreements_from_db()
+            cache.set(CACHE_KEY, agreements)
+        return agreements
+
+    def get_agreements_from_cache(self):
+        return cache.get(CACHE_KEY, 'nada')
+
+    def get_agreements_from_db(self):
+        agreements = {}
+        for agreement in Agreement.objects.filter('is_active'):
+            agreements[agreement.type] = {
+                'type': agreement.type,
+                'title': agreement.title,
+                'link': agreement.link,
+                'text': bool(agreement.text),
+            }
+        return agreements
 
 
 class Agreement(models.Model):
-    TYPE_TOS = 'terms-of-service'
-    TYPE_PRIVACY = 'privacy-policy'
+    TYPE_TOS = 'terms_of_service'
+    TYPE_PRIVACY = 'privacy_policy'
     TYPE_CHOICES = [
         (TYPE_TOS, _('Terms of service')),
-        (TYPE_PRIVACY, _('Privacy Policy')),
+        (TYPE_PRIVACY, _('Privacy policy')),
     ]
 
     type = models.CharField(
@@ -21,9 +49,9 @@ class Agreement(models.Model):
         choices=TYPE_CHOICES,
         db_index=True,
     )
-    version = models.SlugField(unique=True, blank=True)
-    title = models.CharField(max_length=255)
-    text = models.TextField()
+    title = models.CharField(max_length=255, null=True, blank=True)
+    link = models.URLField(max_length=255, null=True, blank=True)
+    text = models.TextField(null=True, blank=True)
     is_active = models.BooleanField(default=False)
     created_on = models.DateTimeField(default=timezone.now)
     created_by = models.ForeignKey(
@@ -42,6 +70,19 @@ class Agreement(models.Model):
     )
     last_modified_by_name = models.CharField(max_length=255, null=True, blank=True)
 
+    objects = AgreementManager()
+
+    def get_final_title(self):
+        return self.title or self.get_type_display()
+
+    def set_created_by(self, user):
+        self.created_by = user
+        self.created_by_name = user.username
+
+    def set_last_modified_by(self, user):
+        self.last_modified_by = user
+        self.last_modified_by_name = user.username
+
 
 class UserAgreement(models.Model):
     user = models.ForeignKey(

+ 0 - 0
misago/legal/tests/__init__.py


+ 0 - 0
misago/legal/tests/test_admin_views.py


+ 0 - 0
misago/legal/tests.py → misago/legal/tests/test_views.py


+ 9 - 0
misago/legal/utils.py

@@ -0,0 +1,9 @@
+from .models import Agreement
+
+
+def set_agreement_as_active(agreement, commit=False):
+    agreement.is_active = True
+    queryset = Agreement.objects.filter(type=agreement.type).exclude(pk=agreement.pk)
+    queryset.update(is_active=False)
+    agreement.save(update_fields=['is_active'])
+    Agreement.objects.invalidate_cache()

+ 87 - 0
misago/legal/views/admin.py

@@ -0,0 +1,87 @@
+from django.contrib import messages
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+
+from misago.admin.views import generic
+
+from misago.legal.forms import AgreementForm, SearchAgreementsForm
+from misago.legal.models import Agreement
+from misago.legal.utils import set_agreement_as_active
+
+
+class AgreementAdmin(generic.AdminBaseMixin):
+    root_link = 'misago:admin:users:agreements:index'
+    model = Agreement
+    form = AgreementForm
+    templates_dir = 'misago/admin/agreements'
+    message_404 = _("Requested agreement does not exist.")
+
+    def handle_form(self, form, request, target):
+        form.save()
+        
+        if self.message_submit:
+            messages.success(request, self.message_submit % {'title': target.get_final_title()})
+
+
+class AgreementsList(AgreementAdmin, generic.ListView):
+    items_per_page = 30
+    ordering = [
+        ('-id', _("From newest")),
+        ('id', _("From oldest")),
+    ]
+    search_form = SearchAgreementsForm
+    selection_label = _('With agreements: 0')
+    empty_selection_label = _('Select agreements')
+    mass_actions = ({
+        'action': 'delete',
+        'icon': 'fa fa-times',
+        'name': _('Delete agreements'),
+        'confirmation': _('Are you sure you want to delete those agreements?')
+    }, )
+
+    def get_queryset(self):
+        qs = super(AgreementsList, self).get_queryset()
+        return qs.select_related()
+
+    def action_delete(self, request, items):
+        items.delete()
+        Agreement.objects.invalidate_cache()
+        messages.success(request, _("Selected agreements have been deleted."))
+
+
+class NewAgreement(AgreementAdmin, generic.ModelFormView):
+    message_submit = _('New agreement "%(title)s" has been saved.')
+    
+    def handle_form(self, form, request, target):
+        super(NewAgreement, self).handle_form(form, request, target)
+
+        form.instance.set_created_by(request.user)
+        form.instance.save()
+
+
+class EditAgreement(AgreementAdmin, generic.ModelFormView):
+    message_submit = _('Agreement "%(title)s" has been edited.')
+
+    def handle_form(self, form, request, target):
+        super(EditAgreement, self).handle_form(form, request, target)
+
+        form.instance.last_modified_on = timezone.now()
+        form.instance.set_last_modified_by(request.user)
+        form.instance.save()
+
+
+class DeleteAgreement(AgreementAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        target.delete()
+        Agreement.objects.invalidate_cache()
+        message = _('Agreement "%(title)s" has been deleted.')
+        messages.success(request, message % {'title': target.get_final_title()})
+
+
+class SetAgreementAsActive(AgreementAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        set_agreement_as_active(target)
+
+        message = _('Agreement "%(title)s" has been set as active for type "%(type)s".')
+        targets_names = {'title': target.get_final_title(), 'type': target.get_type_display()}
+        messages.success(request, message % targets_names)

+ 68 - 0
misago/templates/misago/admin/agreements/form.html

@@ -0,0 +1,68 @@
+{% extends "misago/admin/generic/form.html" %}
+{% load i18n misago_forms %}
+
+
+{% block title %}
+{% if target.pk %}
+{% trans target.get_final_title %}
+{% else %}
+{% trans "New agreement" %}
+{% endif %} | {{ active_link.name }} | {{ block.super }}
+{% endblock title %}
+
+
+{% block page-target %}
+{% if target.pk %}
+{% trans target.get_final_title %}
+{% else %}
+{% trans "New agreement" %}
+{% endif %}
+{% endblock page-target %}
+
+
+{% block form-header %}
+<h1>
+  {% if target.pk %}
+  {% trans target.get_final_title %}
+  {% else %}
+  {% trans "New agreement" %}
+  {% endif %}
+</h1>
+{% endblock %}
+
+
+{% block form-extra %}
+class="form-horizontal"
+{% endblock form-extra%}
+
+
+{% block form-body %}
+<div class="form-body">
+  {% with label_class="col-md-3" field_class="col-md-9" %}
+  <fieldset>
+    <legend>{% trans "Basic settings" %}</legend>
+
+    {% form_row form.type label_class field_class %}
+    {% form_row form.title label_class field_class %}
+    {% form_row form.is_active "col-md-offset-3" field_class %}
+
+  </fieldset>
+  <fieldset>
+    <legend>{% trans "Agreement contents" %}</legend>
+
+    <div class="form-group">
+      <p>{% trans "Fill in one of the fields." %}</p>
+    </div>
+
+    {% form_row form.link label_class field_class %}
+    {% form_row form.text label_class field_class %}
+
+  </fieldset>
+  {% endwith %}
+</div>
+{% endblock form-body %}
+
+
+{% block form-footer-class %}
+col-md-offset-3
+{% endblock form-footer-class %}

+ 148 - 0
misago/templates/misago/admin/agreements/list.html

@@ -0,0 +1,148 @@
+{% extends "misago/admin/generic/list.html" %}
+{% load i18n misago_capture misago_forms %}
+
+
+{% block page-actions %}
+<div class="page-actions">
+  <a href="{% url 'misago:admin:users:agreements:new' %}" class="btn btn-success">
+    <span class="fa fa-plus-circle"></span>
+    {% trans "New agreement" %}
+  </a>
+</div>
+{% endblock %}
+
+
+{% block table-header %}
+<th>{% trans "Title" %}</th>
+<th style="width: 1%;">&nbsp;</th>
+<th style="width: 180px;">{% trans "Type" %}</th>
+<th style="width: 250px;">{% trans "Created" %}</th>
+<th style="width: 250px;">{% trans "Modified" %}</th>
+<th style="width: 1%;">&nbsp;</th>
+<th style="width: 1%;">&nbsp;</th>
+<th style="width: 1%;">&nbsp;</th>
+{% endblock table-header %}
+
+
+{% block table-row %}
+<td class="item-name">
+  {{ item.get_final_title }}
+</td>
+<td class="lead text-muted">
+  {% if item.is_active %}
+    <span class="fa fa-check-square tooltip-top" title="{% blocktrans trimmed with type=item.get_type_display.lower %}Active {{ type }}{% endblocktrans %}"></span>
+  {% else %}
+    &nbsp;
+  {% endif %}
+</td>
+<td>
+  {{ item.get_type_display }}
+</td>
+<td>
+  {% capture trimmed as created_on %}
+    <abbr class="moment" data-iso="{{ item.created_on.isoformat }}" data-format="LL"></abbr>
+  {% endcapture %}
+  {% capture trimmed as created_by %}
+    {% if item.created_by %}
+      <a href="{{ item.created_by.get_absolute_url }}" class="item-title">{{ item.created_by }}</a>
+    {% else %}
+      <span class="item-title">{{ item.created_by_name }}</span>
+    {% endif %}
+  {% endcapture %}
+  {% blocktrans trimmed with created_on=created_on|safe created_by=created_by|safe %}
+    {{ created_on }} by {{ created_by }}
+  {% endblocktrans %}
+</td>
+<td>
+  {% if item.last_modified_on %}
+    {% capture trimmed as last_modified_on %}
+      <abbr class="moment" data-iso="{{ item.last_modified_on.isoformat }}" data-format="LL"></abbr>
+    {% endcapture %}
+    {% capture trimmed as last_modified_by %}
+      {% if item.last_modified_by %}
+        <a href="{{ item.last_modified_by.get_absolute_url }}" class="item-title">{{ item.last_modified_by }}</a>
+      {% else %}
+        <span class="item-title">{{ item.last_modified_by }}</span>
+      {% endif %}
+    {% endcapture %}
+    {% blocktrans trimmed with last_modified_on=last_modified_on|safe last_modified_by=last_modified_by|safe %}
+      {{ last_modified_on }} by {{ last_modified_by }}
+    {% endblocktrans %}
+  {% else %}
+    <em>{% trans "never" %}</em>
+  {% endif %}
+</td>
+<td class="row-action">
+  {% if not item.is_active %}
+    <form action="{% url 'misago:admin:users:agreements:set-as-active' pk=item.pk %}" method="post" class="set-as-active-prompt">
+      <button class="btn btn-primary tooltip-top" title="{% trans "Set as active" %}">
+        {% csrf_token %}
+        <span class="fa fa-check-square"></span>
+      </button>
+    </form>
+  {% else %}
+    &nbsp;
+  {% endif %}
+</td>
+<td class="row-action">
+  <a href="{% url 'misago:admin:users:agreements:edit' pk=item.pk %}" class="btn btn-primary tooltip-top" title="{% trans "Edit" %}">
+    <span class="fa fa-pencil"></span>
+  </a>
+</td>
+<td class="row-action">
+  <form action="{% url 'misago:admin:users:agreements:delete' pk=item.pk %}" method="post" class="delete-prompt">
+    <button class="btn btn-danger tooltip-top" title="{% trans "Remove" %}">
+      {% csrf_token %}
+      <span class="fa fa-times"></span>
+    </button>
+  </form>
+</td>
+{% endblock %}
+
+
+{% block emptylist %}
+<td colspan="9">
+  {% if active_filters %}
+  <p>{% trans "No agreements matching search criteria have been found" %}</p>
+  {% else %}
+  <p>{% trans "No agreements are currently set." %}</p>
+  {% endif %}
+</td>
+{% endblock emptylist %}
+
+
+{% block javascripts %}
+{{ block.super }}
+<script type="text/javascript">
+  $(function() {
+    $('.set-as-active-prompt').submit(function() {
+      var decision = confirm("{% trans 'Are you sure you want to set this agreement as active for its type?' %}");
+      return decision;
+    });
+
+    $('.delete-prompt').submit(function() {
+      var decision = confirm("{% trans 'Are you sure you want to delete this agreement?' %}");
+      return decision;
+    });
+  });
+</script>
+{% endblock %}
+
+
+{% block modal-title %}
+{% trans "Search bans" %}
+{% endblock modal-title %}
+
+
+{% block modal-body %}
+<div class="row">
+  <div class="col-md-12">
+    {% form_row search_form.type %}
+  </div>
+</div>
+<div class="row">
+  <div class="col-md-12">
+    {% form_row search_form.content %}
+  </div>
+</div>
+{% endblock modal-body %}