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.account import AccountBlueprint
from calsocial.cache import CachedSessionInterface, cache from calsocial.cache import CachedSessionInterface, cache
from calsocial.group import GroupBlueprint
from calsocial.utils import RoutedMixin from calsocial.utils import RoutedMixin
@ -109,6 +110,7 @@ class CalendarSocialApp(Flask, RoutedMixin):
RoutedMixin.register_routes(self) RoutedMixin.register_routes(self)
AccountBlueprint().init_app(self, '/accounts/') AccountBlueprint().init_app(self, '/accounts/')
GroupBlueprint().init_app(self, '/groups/')
self.before_request(self.goto_first_steps) 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.validators import DataRequired, Email, StopValidation, ValidationError
from wtforms.widgets import TextArea 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 class UsernameAvailable: # pylint: disable=too-few-public-methods
@ -411,3 +412,43 @@ class ProfileForm(FlaskForm):
self.builtin_avatar.choices = [(name, name) self.builtin_avatar.choices = [(name, name)
for name in current_app.config['BUILTIN_AVATARS']] for name in current_app.config['BUILTIN_AVATARS']]
self.profile = profile 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: class SettingsProxy:
"""Proxy object to get settings for a user """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): def __repr__(self):
return f'<AppState {self.env}:{self.key}="{self.value}"' 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 %} {%- endtrans %}
</div> </div>
<a class="item" href="{{ url_for('hello') }}">{% trans %}Calendar view{% endtrans %}</a> <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.notifications') }}">{% trans %}Notifications{% endtrans %}</a>
<a class="item" href="{{ url_for('account.settings') }}">{% trans %}Settings{% 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> <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 %}