Compare commits
3 Commits
response-v
...
groups
Author | SHA1 | Date | |
---|---|---|---|
74530347e2 | |||
eaf71d4ce6 | |||
7cd2156cfc |
@@ -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)
|
||||||
|
|
||||||
|
@@ -28,10 +28,11 @@ 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(object): # pylint: disable=too-few-public-methods
|
class UsernameAvailable: # pylint: disable=too-few-public-methods
|
||||||
"""Checks if a username is available
|
"""Checks if a username is available
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ class UsernameAvailable(object): # pylint: disable=too-few-public-methods
|
|||||||
raise StopValidation(message)
|
raise StopValidation(message)
|
||||||
|
|
||||||
|
|
||||||
class EmailAvailable(object): # pylint: disable=too-few-public-methods
|
class EmailAvailable: # pylint: disable=too-few-public-methods
|
||||||
"""Checks if an email address is available
|
"""Checks if an email address is available
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -408,5 +409,46 @@ class ProfileForm(FlaskForm):
|
|||||||
})
|
})
|
||||||
FlaskForm.__init__(self, *args, **kwargs)
|
FlaskForm.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
self.builtin_avatar.choices = [(name, name) for name in current_app.config['BUILTIN_AVATARS']]
|
self.builtin_avatar.choices = [(name, name)
|
||||||
|
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 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'))
|
||||||
|
100
calsocial/group.py
Normal file
100
calsocial/group.py
Normal 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)
|
@@ -70,18 +70,6 @@ class NotificationAction(Enum):
|
|||||||
#: A user has been invited to an event
|
#: A user has been invited to an event
|
||||||
invite = 2
|
invite = 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
|
|
||||||
|
|
||||||
return Enum.__eq__(self, other)
|
|
||||||
|
|
||||||
|
|
||||||
NOTIFICATION_ACTION_MESSAGES = {
|
NOTIFICATION_ACTION_MESSAGES = {
|
||||||
NotificationAction.follow: (_('%(actor)s followed you'), _('%(actor)s followed %(item)s')),
|
NotificationAction.follow: (_('%(actor)s followed you'), _('%(actor)s followed %(item)s')),
|
||||||
@@ -113,7 +101,7 @@ class ResponseType(Enum):
|
|||||||
return self.name.lower() == other.lower() # pylint: disable=no-member
|
return self.name.lower() == other.lower() # pylint: disable=no-member
|
||||||
|
|
||||||
if isinstance(other, (int, float)):
|
if isinstance(other, (int, float)):
|
||||||
return self.value == other
|
return self.value == other # pylint: disable=comparison-with-callable
|
||||||
|
|
||||||
return Enum.__eq__(self, other)
|
return Enum.__eq__(self, other)
|
||||||
|
|
||||||
@@ -135,78 +123,62 @@ EVENT_VISIBILITY_TRANSLATIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ResponseVisibility(Enum):
|
class GroupVisibility(Enum):
|
||||||
"""Enumeration for response visibility
|
"""Enumeration for group visibility
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: The response is only visible to the invitee
|
#: The group is secret, ie. completely unlisted
|
||||||
private = 0
|
secret = 0
|
||||||
|
|
||||||
#: The response is only visible to the event organisers
|
#: The group is closed, ie. it can be joined only with an invitation, but otherwise public
|
||||||
organisers = 1
|
closed = 1
|
||||||
|
|
||||||
#: The response is only visible to the event attendees
|
#: The group is public
|
||||||
attendees = 2
|
public = 2
|
||||||
|
|
||||||
#: The response is visible to the invitee’s friends (ie. mutual follows)
|
def __hash__(self):
|
||||||
friends = 3
|
return Enum.__hash__(self)
|
||||||
|
|
||||||
#: The response is visible to the invitee’s followers
|
def __eq__(self, other):
|
||||||
followers = 4
|
if isinstance(other, str):
|
||||||
|
return self.name.lower() == other.lower() # pylint: disable=no-member
|
||||||
|
|
||||||
#: The response is visible to anyone
|
if isinstance(other, (int, float)):
|
||||||
public = 5
|
return self.value == other # pylint: disable=comparison-with-callable
|
||||||
|
|
||||||
|
return Enum.__eq__(self, other)
|
||||||
|
|
||||||
|
|
||||||
RESPONSE_VISIBILITY_TRANSLATIONS = {
|
GROUP_VISIBILITY_TRANSLATIONS = {
|
||||||
ResponseVisibility.private: _('Visible only to myself'),
|
GroupVisibility.secret: _('Secret'),
|
||||||
ResponseVisibility.organisers: _('Visible only to event organisers'),
|
GroupVisibility.closed: _('Closed'),
|
||||||
ResponseVisibility.attendees: _('Visible only to event attendees'),
|
GroupVisibility.public: _('Public'),
|
||||||
ResponseVisibility.friends: _('Visible only to my friends'),
|
|
||||||
ResponseVisibility.followers: _('Visible only to my followers'),
|
|
||||||
ResponseVisibility.public: _('Visible to anyone'),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class EventAvailability(Enum):
|
class GroupMemberLevel(Enum):
|
||||||
"""Enumeration of event availabilities
|
"""Enumeration for group membership level
|
||||||
"""
|
"""
|
||||||
|
|
||||||
free = 0
|
#: The member is a spectator only (ie. read only)
|
||||||
busy = 1
|
spectator = 0
|
||||||
|
|
||||||
def __hash__(self):
|
#: The member is a user with all privileges
|
||||||
return Enum.__hash__(self)
|
user = 1
|
||||||
|
|
||||||
def __eq__(self, other):
|
#: The member is a moderator
|
||||||
if isinstance(other, str):
|
moderator = 2
|
||||||
return self.name.lower() == other.lower() # pylint: disable=no-member
|
|
||||||
|
|
||||||
if isinstance(other, (int, float)):
|
#: The member is an administrator
|
||||||
return self.value == other
|
admin = 3
|
||||||
|
|
||||||
return Enum.__eq__(self, other)
|
|
||||||
|
|
||||||
|
|
||||||
class UserAvailability(Enum):
|
GROUP_MEMBER_LEVEL_TRANSLATIONS = {
|
||||||
"""Enumeration of user availabilities
|
GroupMemberLevel.spectator: _('Spectator'),
|
||||||
"""
|
GroupMemberLevel.user: _('User'),
|
||||||
|
GroupMemberLevel.moderator: _('Moderator'),
|
||||||
free = 0
|
GroupMemberLevel.admin: _('Administrator'),
|
||||||
busy = 1
|
}
|
||||||
tentative = 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
|
|
||||||
|
|
||||||
return Enum.__eq__(self, other)
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsProxy:
|
class SettingsProxy:
|
||||||
@@ -490,45 +462,6 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
|
|||||||
|
|
||||||
return notification
|
return notification
|
||||||
|
|
||||||
def is_following(self, profile):
|
|
||||||
"""Check if this profile is following ``profile``
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
UserFollow.query.filter(UserFollow.follower == self).filter(UserFollow.followed == profile).one()
|
|
||||||
except NoResultFound:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_friend_of(self, profile):
|
|
||||||
"""Check if this profile is friends with ``profile``
|
|
||||||
"""
|
|
||||||
|
|
||||||
reverse = db.aliased(UserFollow)
|
|
||||||
|
|
||||||
try:
|
|
||||||
UserFollow.query \
|
|
||||||
.filter(UserFollow.follower == self) \
|
|
||||||
.join(reverse, UserFollow.followed_id == reverse.follower_id) \
|
|
||||||
.filter(UserFollow.follower_id == reverse.followed_id) \
|
|
||||||
.one()
|
|
||||||
except NoResultFound:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_attending(self, event):
|
|
||||||
"""Check if this profile is attending ``event``
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
Response.query.filter(Response.profile == self).filter(Response.event == event).one()
|
|
||||||
except NoResultFound:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Event(db.Model):
|
class Event(db.Model):
|
||||||
"""Database model for events
|
"""Database model for events
|
||||||
@@ -916,56 +849,8 @@ class Response(db.Model): # pylint: disable=too-few-public-methods
|
|||||||
#: The response itself
|
#: The response itself
|
||||||
response = db.Column(db.Enum(ResponseType), nullable=False)
|
response = db.Column(db.Enum(ResponseType), nullable=False)
|
||||||
|
|
||||||
#: The visibility of the response
|
|
||||||
visibility = db.Column(db.Enum(ResponseVisibility), nullable=False)
|
|
||||||
|
|
||||||
def visible_to(self, profile):
|
class AppState(app_state_base(db.Model)): # pylint: disable=too-few-public-methods,inherit-non-class
|
||||||
"""Checks if the response can be visible to ``profile``.
|
|
||||||
|
|
||||||
:param profile: the profile looking at the response. If None, it is viewed as anonymous
|
|
||||||
:type profile: Profile, None
|
|
||||||
:returns: ``True`` if the response should be visible, ``False`` otherwise
|
|
||||||
:rtype: bool
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.profile == profile:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.visibility == ResponseVisibility.private:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.visibility == ResponseVisibility.organisers:
|
|
||||||
return profile == self.event.profile
|
|
||||||
|
|
||||||
if self.visibility == ResponseVisibility.attendees:
|
|
||||||
return profile is not None and \
|
|
||||||
(profile.is_attending(self.event) or \
|
|
||||||
profile == self.event.profile)
|
|
||||||
|
|
||||||
# From this point on, if the event is not public, only attendees can see responses
|
|
||||||
if self.event.visibility != EventVisibility.public:
|
|
||||||
return profile is not None and \
|
|
||||||
(profile.is_attending(self.event) or
|
|
||||||
profile == self.event.profile)
|
|
||||||
|
|
||||||
if self.visibility == ResponseVisibility.friends:
|
|
||||||
return profile is not None and \
|
|
||||||
(profile.is_friend_of(self.profile) or \
|
|
||||||
profile.is_attending(self.event) or \
|
|
||||||
profile == self.event.profile or \
|
|
||||||
profile == self.profile)
|
|
||||||
|
|
||||||
if self.visibility == ResponseVisibility.followers:
|
|
||||||
return profile is not None and \
|
|
||||||
(profile.is_following(self.profile) or \
|
|
||||||
profile.is_attending(self.event) or \
|
|
||||||
profile == self.event.profile or \
|
|
||||||
profile == self.profile)
|
|
||||||
|
|
||||||
return self.visibility == ResponseVisibility.public
|
|
||||||
|
|
||||||
|
|
||||||
class AppState(app_state_base(db.Model)): # pylint: disable=too-few-public-methods
|
|
||||||
"""Database model for application state values
|
"""Database model for application state values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1024,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, it’s 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])
|
||||||
|
@@ -35,6 +35,15 @@ class AnonymousUser(BaseAnonymousUser):
|
|||||||
|
|
||||||
return current_app.timezone
|
return current_app.timezone
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile(self):
|
||||||
|
"""The profile of the anonymous user
|
||||||
|
|
||||||
|
Always evaluates to ``None``
|
||||||
|
"""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@user_logged_in.connect
|
@user_logged_in.connect
|
||||||
def login_handler(app, user): # pylint: disable=unused-argument
|
def login_handler(app, user): # pylint: disable=unused-argument
|
||||||
|
@@ -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>
|
||||||
|
17
calsocial/templates/group/create.html
Normal file
17
calsocial/templates/group/create.html
Normal 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 %}
|
33
calsocial/templates/group/display.html
Normal file
33
calsocial/templates/group/display.html
Normal 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 %}
|
11
calsocial/templates/group/list.html
Normal file
11
calsocial/templates/group/list.html
Normal 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 %}
|
@@ -1,175 +0,0 @@
|
|||||||
# 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/>.
|
|
||||||
|
|
||||||
"""Profile related tests for Calendar.social
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from calsocial.models import db, Event, EventVisibility, Profile, Response, ResponseType, \
|
|
||||||
ResponseVisibility, UserFollow
|
|
||||||
|
|
||||||
from helpers import database
|
|
||||||
|
|
||||||
|
|
||||||
def test_response_visibility(database):
|
|
||||||
"""Test response visibility in different scenarios
|
|
||||||
"""
|
|
||||||
|
|
||||||
test_data = (
|
|
||||||
# Third element value descriptions:
|
|
||||||
# none=not logged in
|
|
||||||
# unknown=completely unrelated profile
|
|
||||||
# follower=spectator is following respondent
|
|
||||||
# friend=spectator and respondent are friends (mutual follow)
|
|
||||||
# attendee=spectator is an attendee of the event
|
|
||||||
# respondent=spectator is the respondent
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'anon', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'unknown', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'follower', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'friend', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'attendee', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'organiser', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.public, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'anon', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'unknown', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'follower', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'friend', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'attendee', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'organiser', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.followers, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'anon', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'unknown', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'follower', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'friend', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'attendee', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'organiser', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.friends, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'anon', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'unknown', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'follower', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'friend', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'attendee', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'organiser', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.attendees, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'anon', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'unknown', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'follower', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'friend', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'attendee', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'organiser', True),
|
|
||||||
(EventVisibility.public, ResponseVisibility.organisers, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'anon', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'unknown', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'follower', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'friend', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'attendee', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'organiser', False),
|
|
||||||
(EventVisibility.public, ResponseVisibility.private, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'attendee', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'organiser', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.public, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'attendee', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'organiser', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.followers, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'attendee', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'organiser', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.friends, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'attendee', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'organiser', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.attendees, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'attendee', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'organiser', True),
|
|
||||||
(EventVisibility.private, ResponseVisibility.organisers, 'respondent', True),
|
|
||||||
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'anon', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'unknown', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'follower', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'friend', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'attendee', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'organiser', False),
|
|
||||||
(EventVisibility.private, ResponseVisibility.private, 'respondent', True),
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
for evt_vis, resp_vis, relation, exp_ret in test_data:
|
|
||||||
organiser = Profile(display_name='organiser')
|
|
||||||
event = Event(profile=organiser, visibility=evt_vis, title='Test Event', time_zone='UTC', start_time=datetime.utcnow(), end_time=datetime.utcnow())
|
|
||||||
respondent = Profile(display_name='Respondent')
|
|
||||||
response = Response(event=event, visibility=resp_vis, profile=respondent, response=ResponseType.going)
|
|
||||||
|
|
||||||
db.session.add_all([event, response])
|
|
||||||
|
|
||||||
if relation is 'anon':
|
|
||||||
spectator = None
|
|
||||||
elif relation == 'respondent':
|
|
||||||
spectator = respondent
|
|
||||||
elif relation == 'organiser':
|
|
||||||
spectator = organiser
|
|
||||||
else:
|
|
||||||
spectator = Profile(display_name='Spectator')
|
|
||||||
db.session.add(spectator)
|
|
||||||
|
|
||||||
if relation == 'follower' or relation == 'friend':
|
|
||||||
follow = UserFollow(follower=spectator, followed=respondent)
|
|
||||||
db.session.add(follow)
|
|
||||||
|
|
||||||
if relation == 'friend':
|
|
||||||
follow = UserFollow(follower=respondent, followed=spectator)
|
|
||||||
db.session.add(follow)
|
|
||||||
|
|
||||||
if relation == 'attendee':
|
|
||||||
att_response = Response(profile=spectator,
|
|
||||||
event=event,
|
|
||||||
response=ResponseType.going,
|
|
||||||
visibility=ResponseVisibility.public)
|
|
||||||
db.session.add(att_response)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
notvis = ' not' if exp_ret else ''
|
|
||||||
assert_message = f'Response is{notvis} visible to {spectator} ({evt_vis}, {resp_vis}, {relation})'
|
|
||||||
assert response.visible_to(spectator) is exp_ret, assert_message
|
|
Reference in New Issue
Block a user