WIP: Initial groups support #117
@@ -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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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'))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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)
 | 
			
		||||
@@ -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, 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])
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 %}
 | 
			
		||||
		Reference in New Issue
	
	Block a user