diff --git a/calsocial/__init__.py b/calsocial/__init__.py index f2085bd..e4c03c0 100644 --- a/calsocial/__init__.py +++ b/calsocial/__init__.py @@ -29,6 +29,7 @@ from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from calsocial.account import AccountBlueprint from calsocial.cache import CachedSessionInterface, cache +from calsocial.group import GroupBlueprint from calsocial.utils import RoutedMixin @@ -109,6 +110,7 @@ class CalendarSocialApp(Flask, RoutedMixin): RoutedMixin.register_routes(self) AccountBlueprint().init_app(self, '/accounts/') + GroupBlueprint().init_app(self, '/groups/') self.before_request(self.goto_first_steps) diff --git a/calsocial/forms.py b/calsocial/forms.py index 23150c0..68aaebb 100644 --- a/calsocial/forms.py +++ b/calsocial/forms.py @@ -28,7 +28,8 @@ from wtforms.ext.dateutil.fields import DateTimeField from wtforms.validators import DataRequired, Email, StopValidation, ValidationError from wtforms.widgets import TextArea -from calsocial.models import EventVisibility, EVENT_VISIBILITY_TRANSLATIONS +from calsocial.models import EventVisibility, EVENT_VISIBILITY_TRANSLATIONS, GroupVisibility, \ + GROUP_VISIBILITY_TRANSLATIONS class UsernameAvailable: # pylint: disable=too-few-public-methods @@ -411,3 +412,43 @@ class ProfileForm(FlaskForm): self.builtin_avatar.choices = [(name, name) for name in current_app.config['BUILTIN_AVATARS']] self.profile = profile + + +class HandleAvailable: # pylint: disable=too-few-public-methods + """Checks if a group handle is available + """ + + def __init__(self, message=None): + self.message = message + + def __call__(self, form, field): + from sqlalchemy.orm.exc import NoResultFound + + from calsocial.models import Group + + # If there is no data, we don’t raise an error; it’s not the task of this validator to + # check the validity of the username + if not field.data: + return + + try: + Group.query.filter(Group.handle == field.data).one() + except NoResultFound: + return + + if self.message is None: + message = field.gettext('This group handle is not available') + else: + message = self.message + + field.errors[:] = [] + raise StopValidation(message) + + +class GroupForm(FlaskForm): + """Form for editing a group + """ + + handle = StringField(label=_('Handle'), validators=[DataRequired(), HandleAvailable()]) + display_name = StringField(label=_('Display name')) + visibility = EnumField(GroupVisibility, GROUP_VISIBILITY_TRANSLATIONS, label=_('Visibility')) diff --git a/calsocial/group.py b/calsocial/group.py new file mode 100644 index 0000000..66744e0 --- /dev/null +++ b/calsocial/group.py @@ -0,0 +1,100 @@ +# Calendar.social +# Copyright (C) 2018 Gergely Polonkai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Group related endpoints for Calendar.social +""" + +from flask import Blueprint, abort, redirect, render_template, url_for +from flask_security import current_user, login_required + +from calsocial.utils import RoutedMixin + + +class GroupBlueprint(Blueprint, RoutedMixin): + def __init__(self): + Blueprint.__init__(self, 'group', __name__) + + self.app = None + + RoutedMixin.register_routes(self) + + def init_app(self, app, url_prefix=None): + """Initialise the blueprint, registering it with ``app``. + """ + + self.app = app + + app.register_blueprint(self, url_prefix=url_prefix) + + @staticmethod + @RoutedMixin.route('/') + def list_groups(): + """View to list all public groups known by this instance + """ + + from calsocial.models import db, Group, GroupMembership, GroupVisibility + + groups = Group.query + + if current_user.is_authenticated: + groups = groups.outerjoin(GroupMembership) \ + .filter(db.or_(Group.visibility == GroupVisibility.public, + GroupMembership.profile == current_user.profile)) + else: + groups = groups.filter(Group.visibility == GroupVisibility.public) + + return render_template('group/list.html', groups=groups) + + @staticmethod + @login_required + @RoutedMixin.route('/create', methods=['GET', 'POST']) + def create(): + from datetime import datetime + + from .forms import GroupForm + from .models import db, Group, GroupMemberLevel, GroupMembership + + form = GroupForm() + + if form.validate_on_submit(): + group = Group(created_by=current_user.profile) + form.populate_obj(group) + db.session.add(group) + + member = GroupMembership(group=group, + profile=current_user.profile, + level=GroupMemberLevel.admin, + accepted_at=datetime.utcnow(), + accepted_by=current_user.profile) + db.session.add(member) + + db.session.commit() + + return redirect(url_for('group.list')) + + return render_template('group/create.html', form=form) + + @staticmethod + @RoutedMixin.route('/') + def display(fqn): + from .models import Group + + group = Group.get_by_fqn(fqn) + + if not group.visible_to(current_user.profile): + abort(404) + + return render_template('group/display.html', group=group) diff --git a/calsocial/models.py b/calsocial/models.py index 7442b08..b42ebc4 100644 --- a/calsocial/models.py +++ b/calsocial/models.py @@ -123,6 +123,64 @@ EVENT_VISIBILITY_TRANSLATIONS = { } +class GroupVisibility(Enum): + """Enumeration for group visibility + """ + + #: The group is secret, ie. completely unlisted + secret = 0 + + #: The group is closed, ie. it can be joined only with an invitation, but otherwise public + closed = 1 + + #: The group is public + public = 2 + + def __hash__(self): + return Enum.__hash__(self) + + def __eq__(self, other): + if isinstance(other, str): + return self.name.lower() == other.lower() # pylint: disable=no-member + + if isinstance(other, (int, float)): + return self.value == other # pylint: disable=comparison-with-callable + + return Enum.__eq__(self, other) + + +GROUP_VISIBILITY_TRANSLATIONS = { + GroupVisibility.secret: _('Secret'), + GroupVisibility.closed: _('Closed'), + GroupVisibility.public: _('Public'), +} + + +class GroupMemberLevel(Enum): + """Enumeration for group membership level + """ + + #: The member is a spectator only (ie. read only) + spectator = 0 + + #: The member is a user with all privileges + user = 1 + + #: The member is a moderator + moderator = 2 + + #: The member is an administrator + admin = 3 + + +GROUP_MEMBER_LEVEL_TRANSLATIONS = { + GroupMemberLevel.spectator: _('Spectator'), + GroupMemberLevel.user: _('User'), + GroupMemberLevel.moderator: _('Moderator'), + GroupMemberLevel.admin: _('Administrator'), +} + + class SettingsProxy: """Proxy object to get settings for a user """ @@ -851,3 +909,150 @@ class AppState(app_state_base(db.Model)): # pylint: disable=too-few-public-meth def __repr__(self): return f'' + + def __str__(self): + return self.fqn + + @classmethod + def get_by_fqn(cls, fqn): + import re + + match = re.match(r'^!([a-z0-9_]+)(@.*)?', fqn) + + if not match: + raise ValueError(f'Invalid Group FQN {fqn}') + + handle, domain = match.groups() + + return Group.query.filter(Group.handle == handle).filter(Group.domain == domain).one() + + def visible_to(self, profile): + """Checks whether this group is visible to ``profile`` + + It is so if the group is public or closed or, given it is secret, ``profile`` is a member + of the group. + + :param profile: a :class:`Profile` object, or ``None`` to check for anonymous access + :type profile: Profile + :returns: ``True`` if the group is visible, ``False`` otherwise + :rtype: bool + """ + + if self.visibility == GroupVisibility.secret: + try: + GroupMembership.query \ + .filter(GroupMembership.group == self) \ + .filter(GroupMembership.profile == profile) \ + .one() + except NoResultFound: + return False + + return True + + def details_visible_to(self, profile): + """Checks whether the details of this group is visible to ``profile`` + + Details include member list and events shared with this group. + + It is so if the group is public or ``profile`` is a member of the group. + + :param profile: a :class:`Profile` object, or ``None`` to check for anonymous access + :type profile: Profile + :returns: ``True`` if the group is visible, ``False`` otherwise + :rtype: bool + """ + + if self.visibility != GroupVisibility.public: + try: + GroupMembership.query \ + .filter(GroupMembership.group == self) \ + .filter(GroupMembership.profile == profile) \ + .one() + except NoResultFound: + return False + + return True + + +class GroupMembership(db.Model): + """Database model for group membership + """ + + __tablename__ = 'group_members' + + group_id = db.Column(db.Integer(), db.ForeignKey('groups.id'), primary_key=True) + + group = db.relationship('Group') + + profile_id = db.Column(db.Integer(), db.ForeignKey('profiles.id'), primary_key=True) + + profile = db.relationship('Profile', foreign_keys=[profile_id]) + + level = db.Column(db.Enum(GroupMemberLevel), nullable=False) + + requested_at = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False) + + joined_at = db.Column(db.DateTime(), nullable=True) + + accepted_at = db.Column(db.DateTime(), nullable=True) + + accepted_by_id = db.Column(db.Integer(), db.ForeignKey('profiles.id'), nullable=True) + + accepted_by = db.relationship('Profile', foreign_keys=[accepted_by_id]) diff --git a/calsocial/templates/base.html b/calsocial/templates/base.html index bd21da2..c0a1d57 100644 --- a/calsocial/templates/base.html +++ b/calsocial/templates/base.html @@ -34,6 +34,7 @@ {%- endtrans %} {% trans %}Calendar view{% endtrans %} + {% trans %}Groups{% endtrans %} {% trans %}Notifications{% endtrans %} {% trans %}Settings{% endtrans %} {% trans %}Logout{% endtrans %} diff --git a/calsocial/templates/group/create.html b/calsocial/templates/group/create.html new file mode 100644 index 0000000..86c38a4 --- /dev/null +++ b/calsocial/templates/group/create.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% from '_macros.html' import field %} + +{% block content %} +

{% trans %}Create a group{% endtrans %}

+ +
+ {{ form.hidden_tag() }} + + {{ field(form.handle) }} + {{ field(form.display_name) }} + {{ field(form.visibility) }} + + + {% trans %}Cancel{% endtrans %} +
+{% endblock content %} diff --git a/calsocial/templates/group/display.html b/calsocial/templates/group/display.html new file mode 100644 index 0000000..aa1cf1d --- /dev/null +++ b/calsocial/templates/group/display.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% from '_macros.html' import profile_link %} + +{% block content %} +

+ {% if group.visibility == 'secret' %} + [secret] + {% elif group.visibility == 'closed' %} + [closed] + {% elif group.visibility == 'public' %} + [public] + {% endif %} + {{group.display_name }} + {{ group.fqn }} +

+ {% if not current_user.profile or not current_user.profile.is_member_of(group) %} + {% if group.visibility == 'public' %} +Join + {% else %} +Request membership + {% endif %} + {% else %} +Invitation form + {% endif %}
+ {% if group.details_visible_to(current_user.profile) %} +

{% trans %}Members{% endtrans %}

+ {% for member in group.members %} +{{ profile_link(member) }} + {% endfor %} + {% else %} +The details of this grop are not visible to you. + {% endif %} +{% endblock content %} diff --git a/calsocial/templates/group/list.html b/calsocial/templates/group/list.html new file mode 100644 index 0000000..caf81b2 --- /dev/null +++ b/calsocial/templates/group/list.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +

{% trans %}Groups{% endtrans %}

+{% for group in groups %} +{{ group }} +{% else %} +{% trans %}No groups.{% endtrans %} +{% trans %}Do you want to create one?{% endtrans %} +{% endfor %} +{% endblock content %}