WIP: Initial groups support #117

Draft
gergely wants to merge 1 commits from groups into master
8 changed files with 411 additions and 1 deletions

View File

@ -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)

View File

@ -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 dont raise an error; its 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'))

100
calsocial/group.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
"""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('/<fqn>')
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)

View File

@ -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'<AppState {self.env}:{self.key}="{self.value}"'
class Group(db.Model):
"""Database model for groups
"""
__tablename__ = 'groups'
__table_args__ = (
db.UniqueConstraint('handle', 'domain'),
)
id = db.Column(db.Integer(), primary_key=True)
handle = db.Column(db.String(length=50), nullable=False)
domain = db.Column(db.String(length=100), nullable=True)
display_name = db.Column(db.Unicode(length=100), nullable=True)
created_at = db.Column(db.DateTime(), default=datetime.utcnow)
created_by_id = db.Column(db.Integer(), db.ForeignKey('profiles.id'), nullable=False)
created_by = db.relationship('Profile')
default_level = db.Column(db.Enum(GroupMemberLevel))
visibility = db.Column(db.Enum(GroupVisibility), nullable=False)
@property
def members(self):
return Profile.query.join(GroupMembership, GroupMembership.profile_id == Profile.id).filter(GroupMembership.group == self)
@property
def fqn(self):
"""The fully qualified name of the group
For local profiles, this is in the form ``!username``; for remote users, its in the form
``!handle@domain``.
"""
if not self.domain:
return f'!{self.handle}'
return f'!{self.handle}@{self.domain}'
@property
def url(self):
"""Get the URL for this group
"""
from flask import url_for
return url_for('group.display', fqn=self.fqn)
def __repr__(self):
return f'<Group {self.id}: !{self.handle}@{self.domain}>'
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])

View File

@ -34,6 +34,7 @@
{%- endtrans %}
</div>
<a class="item" href="{{ url_for('hello') }}">{% trans %}Calendar view{% endtrans %}</a>
<a class="item" href="{{ url_for('group.list_groups') }}">{% trans %}Groups{% endtrans %}</a>
<a class="item" href="{{ url_for('account.notifications') }}">{% trans %}Notifications{% endtrans %}</a>
<a class="item" href="{{ url_for('account.settings') }}">{% trans %}Settings{% endtrans %}</a>
<a class="item" href="{{ url_for('security.logout') }}">{% trans %}Logout{% endtrans %}</a>

View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% from '_macros.html' import field %}
{% block content %}
<h2>{% trans %}Create a group{% endtrans %}</h2>
<form method="post" class="ui form">
{{ form.hidden_tag() }}
{{ field(form.handle) }}
{{ field(form.display_name) }}
{{ field(form.visibility) }}
<button class="ui primary button">{% trans %}Create{% endtrans %}</button>
<a href="{{ url_for('group.list_groups') }}" class="ui button">{% trans %}Cancel{% endtrans %}</a>
</form>
{% endblock content %}

View File

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% from '_macros.html' import profile_link %}
{% block content %}
<h2 class="ui header">
{% if group.visibility == 'secret' %}
[secret]
{% elif group.visibility == 'closed' %}
[closed]
{% elif group.visibility == 'public' %}
[public]
{% endif %}
{{group.display_name }}
<small>{{ group.fqn }}</small>
</h2>
{% 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 %}<br>
{% if group.details_visible_to(current_user.profile) %}
<h2>{% trans %}Members{% endtrans %}</h2>
{% for member in group.members %}
{{ profile_link(member) }}
{% endfor %}
{% else %}
The details of this grop are not visible to you.
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<h2>{% trans %}Groups{% endtrans %}</h2>
{% for group in groups %}
<a href="{{ group.url }}">{{ group }}</a>
{% else %}
{% trans %}No groups.{% endtrans %}
<a href="{{ url_for('group.create') }}">{% trans %}Do you want to create one?{% endtrans %}
{% endfor %}
{% endblock content %}