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 %}
+
+
+{% 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 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 %}