Commit 78f6bd21 authored by Robert's avatar Robert

Merge branch 'release-2-3' into 429-reply-to-author

parents fbb469d4 c850ba67
[bumpversion]
current_version = 2.2.3
current_version = 2.2.5
[bumpversion:file:VERSION]
......@@ -3,4 +3,4 @@ max-line-length = 99
exclude = */migrations, node_modules, stadt/settings/local.py, build,
.pybuild, debian/stadtgestalten, debian/tmp, debian/root,
gitlab-ci-build-venv
ignore = E121,E123,E126,E226,E24,E704,W503
ignore = E121,E123,E126,E226,E24,E704,W503,N802
# DEPRECATED CODE - DO NOT USE!
from django import forms
from features.gestalten import models as entities_models
import functools
......@@ -81,7 +83,7 @@ class EmailGestalt(Email):
has_data = True
def get_data(self, form_data):
return entities_models.Gestalt.get_or_create(form_data)
return entities_models.Gestalt.objects.get_or_create_by_email(form_data)
email_gestalt = functools.partial(fieldclass_factory, EmailGestalt)
......@@ -108,7 +110,7 @@ class CurrentGestalt(Field):
if self.view.request.user.is_authenticated:
return self.view.request.user.gestalt
elif not self.null:
return entities_models.Gestalt.get_or_create(form_data)
return entities_models.Gestalt.objects.get_or_create_by_email(form_data)
else:
return None
......
# Generated by Django 2.0.5 on 2018-06-07 09:28
import core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='permissiontoken',
name='secret_key',
field=models.CharField(default=core.models.generate_token, max_length=15, unique=True),
),
]
......@@ -91,8 +91,8 @@ class PermissionToken(models.Model):
authenticated user is really allowed to access the given resource.
"""
gestalt = models.ForeignKey('gestalten.Gestalt', on_delete=models.CASCADE)
# FIXME: secret_key should be unique
secret_key = models.CharField(max_length=PERMISSION_TOKEN_LENGTH, default=generate_token)
secret_key = models.CharField(
max_length=PERMISSION_TOKEN_LENGTH, default=generate_token, unique=True)
time_created = models.DateTimeField(auto_now_add=True)
# Every feature (e.g. the calendar) defines its own unique string describing its permission
# token (e.g. "calendar-read").
......
{% random STADTGESTALTEN_CLAIMS as claim %}
{% if claim %}
<div class="breadcrumb claim">
<i class="fa fa-bullhorn"></i>
<i class="sg sg-claim"></i>
<span>{{ claim }}</span>
</div>
{% endif %}
......@@ -35,7 +35,7 @@
{% block content_header %}{% endblock %}
{% block heading %}
<header class="content-header">
<header class="content-section-header">
{% block heading_title %}
<h1 class="content-classification">
{% block heading_title_text %}
......
stadtgestalten (2.2.5-1) unstable; urgency=medium
* New upstream release
-- Robert <robert@humbug> Thu, 19 Apr 2018 12:10:17 +0200
stadtgestalten (2.2.4-1) unstable; urgency=medium
* New upstream release
-- Robert <robert@humbug> Tue, 20 Mar 2018 10:49:09 +0100
stadtgestalten (2.2.3-1) unstable; urgency=medium
* New upstream release
......
......@@ -10,8 +10,8 @@ Package: stadtgestalten
Architecture: all
# python3-requests: for django-allauth
# python3-six: for django-mailbox
Depends: python3-pil, python3-requests, python3-six, ${misc:Depends},
${python3:Depends}, moreutils, python3-xapian
Depends: python3-html2text, python3-pil, python3-requests, python3-six,
python3-xapian ${misc:Depends}, ${python3:Depends}, moreutils
Recommends: ghostscript, python3-psycopg2, uwsgi, uwsgi-plugin-python3,
uwsgi-plugin-router-access, ${misc:Recommends}
Suggests: sqlite3, postgresql-client-common
......
# optional:
# MAILTO=admin@example.org
# process incoming mails
# The LMTP daemon (since grouprise 2.3) is supposed to handle incoming mail in the future.
# This requires changes of the SMTP server setup. We will start with single recipient groups, until
# all traffic is handled by LMTP.
*/3 * * * * root chronic sh -c 'stadtctl getmail 2>&1 | grep -vE "^(INFO:|WARNING:django_mailbox.models:.*content-transfer-encoding)"'
# calculate scores for groups and users
......@@ -15,4 +15,4 @@
9,29,49 * * * * root cd /tmp && chronic sh -c "stadtctl retry_deferred 2>&1 | tee -a /var/log/stadtgestalten/mailer-stadtgestalten.log"
# update search index
*/5 * * * * root chronic stadtctl update_index
*/5 * * * * root chronic stadtctl update_index --remove
......@@ -2,5 +2,5 @@
{% block content_type %}
<i class="sg sg-content-type-article"></i>
<span>{% if not association.public %}Interner {% endif %}Artikel</span>
<span>Artikel</span>
{% endblock %}
......@@ -4,5 +4,5 @@
{% block content_type %}
<i class="sg sg-content-type-article"></i>
<span>{% if not association.public %}Interner {% endif %}Artikel</span>
<span>Artikel</span>
{% endblock %}
......@@ -15,13 +15,13 @@ class Association(models.QuerySet):
query = models.Q(public=True)
# authenticated users can view associations for entities they are members in
if user.is_authenticated:
GESTALT_TYPE = contenttypes.ContentType.objects.get_for_model(gestalten.Gestalt)
GROUP_TYPE = contenttypes.ContentType.objects.get_for_model(groups.Group)
gestalt_type = contenttypes.ContentType.objects.get_for_model(gestalten.Gestalt)
group_type = contenttypes.ContentType.objects.get_for_model(groups.Group)
gestalt_groups = groups.Group.objects.filter(memberships__member=user.gestalt)
query |= (
(models.Q(entity_type=GROUP_TYPE)
(models.Q(entity_type=group_type)
& models.Q(entity_id__in=gestalt_groups))
| (models.Q(entity_type=GESTALT_TYPE)
| (models.Q(entity_type=gestalt_type)
& models.Q(entity_id=user.gestalt.id))
)
# if given a container we can allow access to associations for which the user
......
......@@ -14,7 +14,8 @@ class Content(core.models.Model):
# FIXME: remove when django bug #28988 is fixed
poll = models.OneToOneField(
'polls.WorkaroundPoll', null=True, blank=True, related_name='content')
'polls.WorkaroundPoll', null=True, blank=True, related_name='content',
on_delete=models.CASCADE)
title = models.CharField(max_length=255)
image = models.ForeignKey('images.Image', blank=True, null=True, on_delete=models.SET_NULL)
......
{% if user.is_authenticated %}
{% if association.public %}
<span class="btn has-state has-square-icon has-hover-title is-unresponsive" data-state="inactive">
<i class="sg sg-xl sg-privacy-public"></i>
<span class="btn-htitle" data-position="right"
title="Dieser Beitrag kann von allen Menschen gesehen werden, die diese Seite besuchen."></span>
</span>
{% else %}
<span class="btn has-state has-square-icon has-hover-title is-unresponsive" data-state="active">
<i class="sg sg-xl sg-privacy-private"></i>
<span class="btn-htitle" data-position="right"
title="Du kannst diesen Beitrag sehen, weil du Mitglied der Gruppe „{{ association.entity }}“ bist."></span>
</span>
{% endif %}
{% endif %}
<article class="content-preview">
{% if not hide_meta %}
<header class="content-preview-header">
<header class="content-header">
{% block content_meta %}{% include 'content/_info.html' %}{% endblock %}
<span class="content-type" title="{% if association.public %}Beitrag ist öffentlich sichtbar{% else %}Beitrag ist nur für Gruppenmitglieder sichtbar{% endif %}">
{% block content_type %}
<span>{% if not association.public %}Interner {% endif %}Beitrag</span>
{% endblock %}
</span>
<div class="flex-row">
<span class="content-type">
{% block content_type %}
<span>Beitrag</span>
{% endblock %}
</span>
{% include 'content/_content_visibility.html' %}
</div>
</header>
{% endif %}
......@@ -28,6 +31,7 @@
</div>
</div>
</a>
<a href="{{ association.get_absolute_url }}" class="read-more">Zum Beitrag &hellip;</a>
<footer class="content-preview-footer">
<a href="{{ association.get_absolute_url }}" class="read-more">Zum Beitrag &hellip;</a>
</footer>
</article>
Beitrag mit Kommentaren online lesen und kommentieren:
{% if association %}{% url 'content-permalink' association.pk as path %}{% else %}{% url 'content-permalink' object.container.associations.first.pk as path %}{% endif %}{{ path|full_url }}#{% ref object %}
{{ text }}
{{ text|striptags }}
--
{% load rules %}{% has_perm 'contributions.reply_to_author' recipient.user object as can_reply_to_author %}{% if can_reply_to_author %}
Nur an {{ object.author }} antworten:
{% if association %}{% url 'reply-to-author' association.pk object.pk as path %}{% else %}{% url 'reply-to-author' object.container.associations.first.pk object.pk as path %}{% endif %}{{ path|full_url }}{% endif %}
{% if contribution %}Du erhältst diese Benachrichtigung, weil Du den Beitrag {{ object.container.subject }} kommentiert hast.
{% url 'content-permalink' object.container.associations.first.pk as path %}{{ path|full_url }}#{% ref contribution %}{% elif subscription and membership %}Du erhältst diese Benachrichtigung, weil Du Mitglied der Gruppe {{ membership.group }} bist und die Gruppe abonniert hast.
{{ subscription.subscribed_to.get_absolute_url|full_url }}{% endif %}
{% url 'content-permalink' object.container.associations.first.pk as path %}{{ path|full_url }}#{% ref contribution %}{% elif subscription and membership %}Du erhältst diese Benachrichtigung, weil Du Mitglied der Gruppe '{{ membership.group }}' bist und die Gruppe abonniert hast. Zum Verlassen der Gruppe folge bitte diesem Verweis:
{% if membership.member.can_login %}{% url 'resign' membership.group.pk as resign_url %}{% else %}{% url 'resign-request' membership.group.pk as resign_url %}{% endif %}{{ resign_url|full_url }}{% endif %}
{% block content %}{% endblock %}
{% block footer %}{% if subscription %}
--
Du erhältst diese Benachrichtigung, weil Du {% if membership %}Mitglied der Gruppe {{ membership.group }} bist und die Gruppe{% else %}die Gruppe {{ subscription.subscribed_to }}{% endif %} abonniert hast.
{{ subscription.subscribed_to.get_absolute_url|full_url }}{% endif %}{% endblock %}
Du erhältst diese Benachrichtigung, weil Du {% if membership %}Mitglied der Gruppe '{{ membership.group }}' bist und die Gruppe{% else %}die Gruppe '{{ subscription.subscribed_to }}'{% endif %} abonniert hast. Zum {% if membership %}Verlassen der Gruppe{% else %}Abbestellen der Benachrichtigungen{% endif %} folge bitte diesem Verweis:
{% if membership %}{% if membership.member.can_login %}{% url 'resign' membership.group.pk as resign_url %}{% else %}{% url 'resign-request' membership.group.pk as resign_url %}{% endif %}{{ resign_url|full_url }}{% else %}{% if subscription.subscriber.can_login %}{% url 'group-unsubscribe' subscription.subscribed_to.pk as unsub_url %}{% else %}{% url 'group-unsubscribe-request' subscription.subscribed_to.pk as unsub_url %}{% endif %}{{ unsub_url|full_url }}{% endif %}{% endif %}{% endblock %}
......@@ -23,13 +23,14 @@
{% block content %}
<article class="content">
<header class="content-teaser">
<header class="content-header">
{% block content_meta %}{% include 'content/_info.html' %}{% endblock %}
<span class="content-type" title="{% if association.public %}Beitrag ist öffentlich sichtbar{% else %}Beitrag ist nur für Gruppenmitglieder sichtbar{% endif %}">
{% block content_type %}
<span>{% if not association.public %}Interner {% endif %}Beitrag</span>
<span>Beitrag</span>
{% endblock %}
</span>
{% include 'content/_content_visibility.html' %}
</header>
<div class="content-body">
......@@ -45,7 +46,7 @@
<div class="clearfix"></div>
<footer class="content-footer">
<h2 class="content-header">
<h2 class="content-section-header">
<span class="content-classification">Kommentare</span>
<span class="decoration-icon sg-comments" role="presentation"></span>
</h2>
......
......@@ -24,7 +24,7 @@
Besondere Formatierungen lassen sich mit den Knöpfen oben erzeugen.
{% endif %}
Erläuterungen gibt es in der
<a href="/stadt/markdown/"><i class="fa fa-question-circle">&nbsp;</i>Hilfe zur Textauszeichnung</a>.
<a href="/stadt/markdown/"><i class="sg sg-info">&nbsp;</i>Hilfe zur Textauszeichnung</a>.
</p>
</div>
{% block form_extra %}{% endblock %}
......
# Generated by Django 2.0.5 on 2018-05-29 08:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contributions', '0010_auto_20180123_1057'),
]
operations = [
migrations.AlterField(
model_name='contribution',
name='attached_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='attachments', to='contributions.Contribution'),
),
]
......@@ -36,7 +36,8 @@ class Contribution(core.models.Model):
author = models.ForeignKey(
'gestalten.Gestalt', related_name='contributions', on_delete=models.PROTECT)
attached_to = models.ForeignKey(
'Contribution', null=True, related_name='attachments', on_delete=models.SET_NULL)
'Contribution', null=True, related_name='attachments', on_delete=models.SET_NULL,
blank=True)
deleted = models.DateTimeField(null=True, blank=True)
in_reply_to = models.ForeignKey(
'Contribution', null=True, blank=True, related_name='replies',
......
import logging
import django.conf
import django.db.models.signals
import django_mailbox.signals
from django.conf import settings
from django.db.models.functions import Lower
from django.dispatch import receiver
import core.models
from features.associations import models as associations
from features.content.models import Content
from features.conversations import models as conversations
from features.files import models as files
from features.gestalten import models as gestalten
from features.groups import models as groups
from . import models, notifications
from . import notifications
logger = logging.getLogger(__name__)
post_create = django.dispatch.Signal(providing_args=['instance'])
......@@ -24,136 +10,3 @@ post_create = django.dispatch.Signal(providing_args=['instance'])
@receiver(post_create)
def contribution_created(sender, instance, **kwargs):
notifications.ContributionCreated.send_all(instance)
def get_sender(message):
try:
gestalt = gestalten.Gestalt.objects.annotate(email=Lower('user__email')).get(
email=message.from_address[0].lower())
except gestalten.Gestalt.DoesNotExist:
try:
gestalt = gestalten.Gestalt.objects.annotate(
email=Lower('user__emailaddress__email')).get(
email=message.from_address[0].lower())
except gestalten.Gestalt.DoesNotExist:
gestalt = None
return gestalt
def is_autoresponse(msg):
email = msg.get_email_object()
# RFC 3834 (https://tools.ietf.org/html/rfc3834#section-5)
if email.get('Auto-Submitted') == 'no':
return False
elif email.get('Auto-Submitted'):
return True
# non-standard fields (https://tools.ietf.org/html/rfc3834#section-3.1.8)
if email.get('Precedence') == 'bulk':
return True
if email.get('X-AUTORESPONDER'):
return True
return False
@receiver(django_mailbox.signals.message_received)
def process_incoming_message(sender, message, **args):
token_beg = len(django.conf.settings.DEFAULT_REPLY_TO_EMAIL.split('{')[0])
token_end = len(django.conf.settings.DEFAULT_REPLY_TO_EMAIL.rsplit('}')[1])
DOMAIN = django.conf.settings.DEFAULT_REPLY_TO_EMAIL.split('@')[1]
def create_contribution(container, gestalt, message, in_reply_to=None):
text = message.text.strip()
if text == '':
text = message.html
t = models.Text.objects.create(text=text)
contribution = models.Contribution.objects.create(
author=gestalt,
container=container,
in_reply_to=in_reply_to,
contribution=t)
files.File.objects.create_from_message(message, attached_to=contribution)
return contribution
def process_reply(address):
token = address[token_beg:-token_end]
try:
in_reply_to_text = models.Contribution.objects.get_by_message_id(
message.get_email_object().get('In-Reply-To'))
except models.Contribution.DoesNotExist:
in_reply_to_text = None
key = core.models.PermissionToken.objects.get(secret_key=token)
sender = get_sender(message)
if key.gestalt != sender:
raise django.core.exceptions.PermissionDenied(
'Du darfst diese Benachrichtigung nicht unter dieser E-Mail-Adresse '
'beantworten. Du hast folgende Möglichkeiten:\n'
'* Melde Dich auf der Website an und beantworte die Nachricht dort.\n'
'* Antworte unter der E-Mail-Adresse, an die die Benachrichtigung '
'gesendet wurde.\n'
'* Füge die E-Mail-Adresse, unter der Du antworten möchtest, Deinem '
'Benutzerkonto hinzu.')
if type(key.target) == Content:
container = key.target
else:
container = key.target.container
contribution = create_contribution(
container, key.gestalt, message, in_reply_to=in_reply_to_text)
post_create.send(sender=None, instance=contribution)
def process_initial(address):
local, domain = address.split('@')
if domain != DOMAIN:
raise ValueError('Domain does not match.')
gestalt = get_sender(message)
group = groups.Group.objects.get(slug=local)
if gestalt and gestalt.user.has_perm(
'conversations.create_group_conversation_by_email', group):
conversation = conversations.Conversation.objects.create(subject=message.subject)
contribution = create_contribution(conversation, gestalt, message)
associations.Association.objects.create(
entity_type=group.content_type, entity_id=group.id,
container_type=conversation.content_type, container_id=conversation.id)
post_create.send(sender=None, instance=contribution)
else:
raise django.core.exceptions.PermissionDenied(
'Du darfst mit dieser Gruppe kein Gespräch per E-Mail beginnen. Bitte '
'verwende die Schaltfläche auf der Webseite.')
def process_message(address):
try:
process_reply(address)
except core.models.PermissionToken.DoesNotExist:
if address == settings.STADTGESTALTEN_BOT_EMAIL:
for to_address in message.to_addresses:
process_initial(to_address)
else:
process_initial(address)
delivered_to = message.get_email_object()['Delivered-To']
if not delivered_to:
logger.error('Could not process message {}: no Delivered-To header'.format(message.id))
return
for address in [delivered_to]:
address = address.lstrip('<')
address = address.rstrip('>')
if not is_autoresponse(message):
try:
process_message(address)
except ValueError as e:
logger.error('Could not process receiver {} in message {}. {}'.format(
address, message.id, e))
except (groups.Group.DoesNotExist, django.core.exceptions.PermissionDenied) as e:
logger.warning('Could not process receiver {} in message {}. {}'.format(
address, message.id, e))
django.core.mail.send_mail(
'Re: {}'.format(message.subject),
'Konnte die Nachricht nicht verarbeiten. {}'.format(e),
from_email=django.conf.settings.DEFAULT_FROM_EMAIL,
recipient_list=[message.from_header],
fail_silently=True)
else:
logger.warning('Ignored message {} as autoresponse'.format(message.id))
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from django_mailbox import models as mailbox_models, signals as mailbox_signals
import features.articles.tests
from core import tests
from features.associations import models as associations
from features.associations.models import Association
from features.contributions.models import Contribution
from features.conversations.models import Conversation
from features.gestalten import tests as gestalten
from features.memberships import test_mixins as memberships
from features.memberships.test_mixins import AuthenticatedMemberMixin
from . import models
......@@ -27,69 +22,6 @@ class ContributionMixin(features.articles.tests.ArticleMixin):
self.contribution = self.create_contribution()
class ContentReplyByEmail(AuthenticatedMemberMixin, tests.Test):
def test_content_reply_by_email(self):
# create article
self.client.post(
reverse('create-group-article', args=(self.group.slug,)),
{'title': 'Test', 'text': 'Test'})
a = self.assertExists(associations.Association, content__title='Test')
self.assertNotificationSent()
# generate reply message
reply_to = mail.outbox[0].extra_headers['Reply-To']
msg = mailbox_models.Message(
from_header=self.gestalt.user.email,
body='Delivered-To: {}\n\nText B'.format(reply_to))
# send signal like getmail would
mailbox_signals.message_received.send(self, message=msg)
self.assertExists(
models.Contribution, content=a.content.get(),
text__text='Text B')
class ConversationInitiateByEmail(memberships.MemberMixin, tests.Test):
def test_conversation_initiate_by_email(self):
# generate initial message
msg = mailbox_models.Message(
from_header=self.gestalt.user.email,
body='Delivered-To: {}@localhost\n\nText A'.format(self.group.slug))
# send signal like getmail would
mailbox_signals.message_received.send(self, message=msg)
self.assertExists(
models.Contribution, conversation__associations__group=self.group,
text__text='Text A')
def test_conversation_initiate_by_email_failing(self):
# generate initial message
msg = mailbox_models.Message(
from_header=self.gestalt.user.email,
body='Delivered-To: not-existing@localhost\n\nText A')
# send signal like getmail would
mailbox_signals.message_received.send(self, message=msg)
self.assertEqual(len(mail.outbox), 1)
class ConversationReplyByEmail(
gestalten.AuthenticatedMixin, gestalten.OtherGestaltMixin, tests.Test):
def test_texts_reply_by_email(self):
# send message to other_gestalt via web interface
self.client.post(
self.get_url('create-gestalt-conversation', self.other_gestalt.pk),
{'subject': 'Subject A', 'text': 'Text A'})
text_a = self.assertExists(models.Contribution, conversation__subject='Subject A')
self.assertNotificationSent()
# generate reply message
reply_to = mail.outbox[0].extra_headers['Reply-To']
msg = mailbox_models.Message(
from_header=self.other_gestalt.user.email,
body='Delivered-To: {}\n\nText B'.format(reply_to))
# send signal like getmail would
mailbox_signals.message_received.send(self, message=msg)
self.assertExists(
models.Contribution, conversation=text_a.conversation.get(),
text__text='Text B')
class Delete(ContributionMixin, TestCase):
def test_delete_contribution(self):
delete_url = reverse(
......
......@@ -14,6 +14,47 @@ from features.conversations.views import CreateGestaltConversation
from . import models
class ContributionFormMixin(django.views.generic.edit.FormMixin):
def can_post(self):
return self.request.user.has_perms((self.permission_required_post,), self.object)
def form_valid(self, form):
form.save()
return super().form_valid(form)
def get_form(self):
if self.can_post():
return super().get_form()
return None
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['contributions'] = self.object.container.contributions
kwargs['instance'] = models.Contribution(
author=self.request.user.gestalt, container=self.object.container)
return kwargs
def has_permission(self):
self.object = self.get_object()
if self.request.method == 'GET':
return super().has_permission()
elif self.request.method == 'POST':
return self.can_post()
else:
return False
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_success_url(self):
return self.object.get_absolute_url()
class Delete(AssociationMixin, core.views.PermissionMixin, django.views.generic.UpdateView):
permission_required = 'contributions.delete'
model = models.Contribution
......
......@@ -51,7 +51,7 @@ class Create(forms.ModelForm):
self.contribution.contribution = contributions_models.Text.objects.create(
text=self.cleaned_data['text'])
if 'author' in self.cleaned_data:
self.contribution.author = gestalten.Gestalt.get_or_create(
self.contribution.author = gestalten.Gestalt.objects.get_or_create_by_email(
self.cleaned_data['author'])
self.contribution.save()
......
{% with c=association.container %}
<a href="{% url 'conversation' association.pk %}" class="thread-preview">
<a href="{% url 'conversation' association.pk %}" class="thread-preview content-internal">
<div class="thread-preview-image">
{% if group_avatar and association.entity.is_group %}
{% include 'groups/_avatar.html' with group=association.entity link=False size=64 %}
......
......@@ -2,7 +2,7 @@
{% has_perm 'conversations.create_group_conversation' user group as can_create_message %}
<header class="content-header">
<header class="content-section-header">
<h2 class="content-classification">Gespräche</h2>
<span class="decoration-icon sg-comments" role="presentation"></span>
{% if can_create_message %}
......
{{ text }}
{{ text|striptags }}
--
Nachricht online lesen und beantworten:
......@@ -9,6 +9,6 @@ Nur an {{ object.author }} antworten:
{% if contribution %}
Du erhältst diese Benachrichtigung, weil Du Dich an diesem Gespräch beteiligt hast.
{% url 'conversation' object.container.associations.first.pk as path %}{{ path|full_url }}#{% ref contribution %}
{% elif subscription %}
Du erhältst diese Benachrichtigung weil Du {% if membership %}Mitglied der Gruppe {{ membership.group }} bist und die Gruppe{% else %}die Gruppe {{ subscription.subscribed_to }}{% endif %} abonniert hast.
{{ subscription.subscribed_to.get_absolute_url|full_url }}{% endif %}