1 Commits

Author SHA1 Message Date
86f86996f6 Add Drone configuration
Some checks failed
the build failed
2018-07-11 11:08:16 +02:00
16 changed files with 158 additions and 647 deletions

17
.drone.yml Normal file
View File

@@ -0,0 +1,17 @@
pipeline:
build:
image: python:3.6.6-alpine3.6
commands:
- apk update
- apk add gcc libffi-dev linux-headers libc-dev
- pip3.6 install -U pip setuptools pipenv
- pipenv install
lint:
image: python:3.6.6-alpine3.6
commands:
- apk update
- apk add gcc libffi-dev linux-headers libc-dev
- pip3.6 install -U pip setuptools pipenv
- pipenv install
- pipenv install --dev
- pipenv run pylint calsocial

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
__pycache__/
/calsocial/local.db
/messages.pot
/calsocial/translations/*/LC_MESSAGES/*.mo
/app/translations/*/LC_MESSAGES/*.mo
/.pytest_cache/

View File

@@ -170,7 +170,7 @@ class CalendarSocialApp(Flask):
calendar = GregorianCalendar(timestamp.timestamp())
return render_template('index.html', calendar=calendar, user_only=True)
return render_template('index.html', calendar=calendar)
@staticmethod
@route('/register', methods=['POST', 'GET'])
@@ -217,7 +217,7 @@ class CalendarSocialApp(Flask):
form = EventForm()
if form.validate_on_submit():
event = Event(profile=current_user.profile)
event = Event(user=current_user)
form.populate_obj(event)
db.session.add(event)
@@ -255,7 +255,7 @@ class CalendarSocialApp(Flask):
"""
from .forms import InviteForm
from .models import db, Event
from .models import db, Event, Invitation, Notification, NotificationAction
try:
event = Event.query.filter(Event.event_uuid == event_uuid).one()
@@ -267,7 +267,15 @@ class CalendarSocialApp(Flask):
form = InviteForm(event)
if form.validate_on_submit():
event.invite(current_user.profile, invitee=form.invitee.data)
invite = Invitation(event=event, sender=current_user.profile)
form.populate_obj(invite)
db.session.add(invite)
notification = Notification(profile=form.invitee.data,
actor=current_user.profile,
item=event,
action=NotificationAction.invite)
db.session.add(notification)
db.session.commit()
@@ -292,12 +300,11 @@ class CalendarSocialApp(Flask):
@staticmethod
@route('/profile/@<string:username>/follow')
@login_required
def follow_user(username):
"""View for following a user
"""
from .models import db, Profile, User
from .models import db, Profile, User, UserFollow, Notification, NotificationAction
try:
profile = Profile.query.join(User).filter(User.username == username).one()
@@ -305,7 +312,16 @@ class CalendarSocialApp(Flask):
abort(404)
if profile.user != current_user:
profile.follow(follower=current_user.profile)
follow = UserFollow(follower=current_user.profile,
followed=profile,
accepted_at=datetime.utcnow())
db.session.add(follow)
notification = Notification(profile=profile,
actor=current_user.profile,
item=profile,
action=NotificationAction.follow)
db.session.add(notification)
db.session.commit()
@@ -383,83 +399,5 @@ class CalendarSocialApp(Flask):
return render_template('first-steps.html', form=form)
@staticmethod
@route('/edit-profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
"""View for editing ones profile
"""
from .forms import ProfileForm
from .models import db
form = ProfileForm(current_user.profile)
if form.validate_on_submit():
form.populate_obj(current_user.profile)
db.session.add(current_user.profile)
db.session.commit()
return redirect(url_for('edit_profile'))
return render_template('profile-edit.html', form=form)
@staticmethod
@route('/all-events')
def all_events():
"""View for listing all available events
"""
from .calendar_system.gregorian import GregorianCalendar
try:
timestamp = datetime.fromtimestamp(float(request.args.get('date')))
except TypeError:
timestamp = datetime.utcnow()
calendar = GregorianCalendar(timestamp.timestamp())
return render_template('index.html', calendar=calendar, user_only=False)
@staticmethod
@route('/follow-requests')
@login_required
def follow_requests():
"""View for listing follow requests
"""
from .models import UserFollow
requests = UserFollow.query \
.filter(UserFollow.followed == current_user.profile) \
.filter(UserFollow.accepted_at.is_(None))
return render_template('follow-requests.html', requests=requests)
@staticmethod
@route('/follow-request/<int:follower_id>/accept')
@login_required
def accept_follow(follower_id):
"""View for accepting a follow request
"""
from .models import db, UserFollow
try:
req = UserFollow.query \
.filter(UserFollow.followed == current_user.profile) \
.filter(UserFollow.follower_id == follower_id) \
.one()
except NoResultFound:
abort(404)
if req.accepted_at is None:
req.accept()
db.session.add(req)
db.session.commit()
return redirect(url_for('follow_requests'))
app = CalendarSocialApp(__name__)

View File

@@ -330,24 +330,8 @@ class FirstStepsForm(FlaskForm):
display_name = StringField(
label=_('Display name'),
validators=[DataRequired()],
# pylint: disable=line-too-long
description=_('This will be shown to other users as your name. You can use your real name, or any nickname you like.'))
time_zone = TimezoneField(
label=_('Your time zone'),
validators=[DataRequired()],
description=_('The start and end times of events will be displayed in this time zone.'))
class ProfileForm(FlaskForm):
"""Form for editing a user profile
"""
display_name = StringField(label=_('Display name'), validators=[DataRequired()])
locked = BooleanField(label=_('Lock profile'))
def __init__(self, profile, *args, **kwargs):
kwargs.update({'display_name': profile.display_name})
kwargs.update({'locked': profile.locked})
FlaskForm.__init__(self, *args, **kwargs)
self.profile = profile

View File

@@ -246,9 +246,6 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
#: The display name
display_name = db.Column(db.Unicode(length=80), nullable=False)
#: If locked, a profile cannot be followed without the owners consent
locked = db.Column(db.Boolean(), default=False)
@property
def fqn(self):
"""The fully qualified name of the profile
@@ -268,7 +265,7 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
domain = ''
else:
username = self.username
domain = f'@{self.domain}'
domain = '@' + self.domain
return f'<Profile {self.id}(@{username}{domain})>'
@@ -293,8 +290,7 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
return Profile.query \
.join(UserFollow.followed) \
.filter(UserFollow.follower == self) \
.filter(UserFollow.accepted_at.isnot(None))
.filter(UserFollow.follower == self)
@property
def follower_list(self):
@@ -307,23 +303,7 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
return Profile.query \
.join(UserFollow.follower) \
.filter(UserFollow.followed == self) \
.filter(UserFollow.accepted_at.isnot(None))
@property
def friend_list(self):
"""List of friends (ie. where both profiles follow each other)
"""
# This will always be empty for remote profiles
if not self.user:
return []
reverse = db.aliased(UserFollow)
return UserFollow.query \
.filter(UserFollow.follower == self) \
.join(reverse, UserFollow.followed == reverse.follower) \
.filter(UserFollow.follower == reverse.followed)
.filter(UserFollow.followed == self)
@property
def url(self):
@@ -337,48 +317,6 @@ class Profile(db.Model): # pylint: disable=too-few-public-methods
return NotImplemented
def follow(self, follower):
"""Make ``follower`` follow this profile
"""
if not isinstance(follower, Profile):
raise TypeError('Folloer must be a Profile object')
timestamp = None if self.locked else datetime.utcnow()
user_follow = UserFollow(follower=follower, followed=self, accepted_at=timestamp)
db.session.add(user_follow)
notification = self.notify(follower, self, NotificationAction.follow)
db.session.add(notification)
return user_follow
def notify(self, actor, item, action):
"""Notify this profile about ``action`` on ``item`` by ``actor``
:param actor: the actor who generated the notification
:type actor: Profile
:param item: the item ``action`` was performed on
:type item: any
:param action: the type of the action
:type action: NotificationAction, str
:raises TypeError: if ``actor`` is not a `Profile` object
:returns: the generated notification. It is already added to the database session, but
not committed
:rtype: Notification
"""
if not isinstance(actor, Profile):
raise TypeError('actor must be a Profile instance')
if isinstance(action, str):
action = NotificationAction[action]
notification = Notification(profile=self, actor=actor, item=item, action=action)
return notification
class Event(db.Model):
"""Database model for events
@@ -462,20 +400,6 @@ class Event(db.Model):
return url_for('event_details', event_uuid=self.event_uuid)
def invite(self, inviter, invited):
"""Invite ``invited`` to the event
The invitation will arrive from ``inviter``.
"""
invite = Invitation(event=self, sender=inviter, invitee=invited)
db.session.add(invite)
notification = invited.notify(inviter, self, NotificationAction.invite)
db.session.add(notification)
return invite
class UserSetting(db.Model): # pylint: disable=too-few-public-methods
"""Database model for user settings
@@ -619,12 +543,6 @@ class UserFollow(db.Model): # pylint: disable=too-few-public-methods
#: The timestamp when the follow was accepted
accepted_at = db.Column(db.DateTime(), nullable=True)
def accept(self):
"""Accept this follow request
"""
self.accepted_at = datetime.utcnow()
class Notification(db.Model):
"""Database model for notifications

View File

@@ -1,13 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h2>{% trans %}Follow requests{% endtrans %}</h2>
<ul>
{% for req in requests %}
<li>
{{ req.follower }}
<a href="{{ url_for('accept_follow', follower_id=req.follower_id) }}">{% trans %}Accept{% endtrans %}</a>
</li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -120,7 +120,7 @@
{%- endif %}
<td class="{% if day.month != calendar.timestamp.month %} other-month{% endif %}{% if day.date() == now.date() %} today{% endif %}">
<span class="day-num">{{ day.day }}</span>
{% for event in calendar.day_events(day, user=current_user if user_only else none) %}
{% for event in calendar.day_events(day, user=current_user) %}
<a href="{{ url_for('event_details', event_uuid=event.event_uuid) }}" class="event">
{{ event.start_time_for_user(current_user).strftime('%H:%M') }}{{ event.end_time_for_user(current_user).strftime('%H:%M') }}
{{ event.title }}

View File

@@ -2,10 +2,7 @@
{% block content %}
<h1>
{% if profile.locked %}
[locked]
{% endif %}
{{ profile.display_name }}
{{ profile.name }}
<small>@{{ profile.user.username}}</small>
</h1>
{% if profile.user != current_user %}

View File

@@ -1,22 +0,0 @@
{% extends 'settings-base.html' %}
{% block content %}
{{ super() }}
<h2>{% trans %}Edit profile{% endtrans %}</h2>
<form method="post">
{{ form.hidden_tag() }}
{{ form.errors }}
{{ form.display_name.errors }}
{{ form.display_name.label }}
{{ form.display_name }}
<br>
{{ form.locked.errors }}
{{ form.locked.label }}
{{ form.locked}}
<br>
<button type="submit">{% trans %}Save{% endtrans %}</button>
</form>
{% endblock content %}

View File

@@ -1,10 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<nav>
<ul>
<li><a href="{{ url_for('edit_profile') }}">{% trans %}Edit profile{% endtrans %}</a></li>
<li><a href="{{ url_for('settings') }}">{% trans %}Settings{% endtrans %}</a></li>
</ul>
</nav>
{% endblock %}

View File

@@ -1,8 +1,6 @@
{% extends 'settings-base.html' %}
{% extends 'base.html' %}
{% block content %}
{{ super() }}
<h2>{% trans %}Settings{% endtrans %}</h2>
<form method="post">
{{ form.hidden_tag() }}

View File

@@ -1,72 +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/>.
"""Helper functions and fixtures for testing
"""
import pytest
import calsocial
from calsocial.models import db
def configure_app():
"""Set default configuration values for testing
"""
calsocial.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'
calsocial.app.config['TESTING'] = True
calsocial.app.config['WTF_CSRF_ENABLED'] = False
@pytest.fixture
def client():
"""Fixture that provides a Flask test client
"""
configure_app()
client = calsocial.app.test_client()
with calsocial.app.app_context():
db.create_all()
yield client
with calsocial.app.app_context():
db.drop_all()
def login(client, username, password, no_redirect=False):
"""Login with the specified username and password
"""
return client.post('/login',
data={'email': username, 'password': password},
follow_redirects=not no_redirect)
@pytest.fixture
def database():
"""Fixture to provide all database tables in an active application context
"""
configure_app()
with calsocial.app.app_context():
db.create_all()
yield db
db.drop_all()

View File

@@ -1,23 +1,23 @@
# 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/>.
import pytest
"""General tests for Calendar.social
"""
import calsocial
from calsocial.models import db, User
from helpers import client
@pytest.fixture
def client():
calsocial.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'
calsocial.app.config['TESTING'] = True
calsocial.app.config['WTF_CSRF_ENABLED'] = False
client = calsocial.app.test_client()
with calsocial.app.app_context():
db.create_all()
yield client
with calsocial.app.app_context():
db.drop_all()
def test_index_no_login(client):
@@ -26,3 +26,96 @@ def test_index_no_login(client):
page = client.get('/')
assert b'Welcome to Calendar.social' in page.data
def test_register_page(client):
"""Test the registration page
"""
page = client.get('/register')
assert b'Register</button>' in page.data
def test_register_post_empty(client):
"""Test sending empty registration data
"""
page = client.post('/register', data={})
assert b'This field is required' in page.data
def test_register_invalid_email(client):
"""Test sending an invalid email address
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test',
'password': 'password',
'password_retype': 'password',
})
assert b'Invalid email address' in page.data
def test_register_password_mismatch(client):
"""Test sending different password for registration
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'something',
})
assert b'The two passwords must match' in page.data
def test_register(client):
"""Test user registration
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'password',
})
print(page.data)
assert page.status_code == 302
assert page.location == 'http://localhost/'
with calsocial.app.app_context():
user = User.query.one()
assert user.username == 'test'
assert user.email == 'test@example.com'
def test_register_existing_username(client):
"""Test registering an existing username
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com')
db.session.add(user)
db.session.commit()
page = client.post('/register', data={
'username': 'test',
'email': 'test2@example.com',
'password': 'password',
'password_retype': 'password',
})
assert b'This username is not available' in page.data
def test_register_existing_email(client):
"""Test registering an existing email address
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com')
db.session.add(user)
db.session.commit()
page = client.post('/register', data={
'username': 'tester',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'password',
})
assert b'This email address can not be used' in page.data

View File

@@ -1,117 +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
"""
import calsocial
from calsocial.models import db, Notification, NotificationAction, Profile, User, UserFollow
from helpers import client, database, login
def test_profile_follow(database):
"""Test the Profile.follow() method
"""
follower_user = User(username='follower',
email='follower@example.com',
password='passworder',
active=True)
followed_user = User(username='followed', email='followed@example.com')
follower = Profile(display_name='Follower', user=follower_user)
followed = Profile(display_name='Followed', user=followed_user)
db.session.add_all([follower, followed])
db.session.commit()
user_follow = followed.follow(follower=follower)
db.session.commit()
# The new follower record should have the fields set correctly
assert user_follow.followed == followed
assert user_follow.follower == follower
# There should be a notification about the follow
notification = Notification.query.one()
assert notification.actor == follower
assert notification.item == followed
assert notification.action == NotificationAction.follow
assert follower in followed.follower_list
assert followed in follower.followed_list
def test_follow_ui(client):
"""Test following on the web interface
"""
with calsocial.app.app_context():
follower_user = User(username='follower',
email='follower@example.com',
password='passworder',
active=True)
followed_user = User(username='followed', email='followed@example.com')
follower = Profile(display_name='Follower', user=follower_user)
followed = Profile(display_name='Followed', user=followed_user)
db.session.add_all([follower, followed])
db.session.commit()
login(client, 'follower', 'passworder')
page = client.get('/profile/@followed/follow')
assert page.status_code == 302
assert page.location == 'http://localhost/profile/%40followed'
with calsocial.app.app_context():
db.session.add_all([follower, followed])
follow = UserFollow.query.one()
assert follow.follower == follower
assert follow.followed == followed
assert follow.accepted_at is not None
def test_locked_profile(database):
"""Test following a locked profile
"""
follower_user = User(username='follower',
email='follower@example.com',
password='passworder',
active=True)
followed_user = User(username='followed', email='followed@example.com')
follower = Profile(display_name='Follower', user=follower_user)
followed = Profile(display_name='Followed', user=followed_user, locked=True)
db.session.add_all([follower, followed])
db.session.commit()
user_follow = followed.follow(follower=follower)
db.session.commit()
# The new follower record should have the fields set correctly
assert user_follow.followed == followed
assert user_follow.follower == follower
assert not user_follow.accepted_at
# There should be a notification about the follow
notification = Notification.query.one()
assert notification.actor == follower
assert notification.item == followed
assert notification.action == NotificationAction.follow
assert follower not in followed.follower_list
assert followed not in follower.followed_list

View File

@@ -1,84 +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/>.
"""General tests for Calendar.social
"""
import calsocial
from calsocial.models import db, User
from helpers import client, login
def test_index_no_login(client):
"""Test the main page without logging in
"""
page = client.get('/')
assert b'Welcome to Calendar.social' in page.data
def test_login_invalid_user(client):
"""Test logging in with a non-existing user
"""
page = login(client, 'username', 'password')
assert b'Specified user does not exist' in page.data
def test_login_bad_password(client):
"""Test logging in with a bad password
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com', password='password')
db.session.add(user)
db.session.commit()
page = login(client, 'test', password='else')
assert b'Invalid password' in page.data
def test_login_email(client):
"""Test logging in with the email address instead of the username
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com', password='password', active=True)
db.session.add(user)
db.session.commit()
page = login(client, 'test@example.com', password='password')
assert b'Logged in as ' in page.data
def test_login_first_steps(client):
"""Test logging in with a new user
They must be redirected to the first login page
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com', password='password', active=True)
db.session.add(user)
db.session.commit()
page = login(client, 'test', password='password', no_redirect=True)
# First, we must be redirected to the main page
assert page.location == 'http://localhost/'
page = client.get('/')
assert page.location == 'http://localhost/first-steps'

View File

@@ -1,116 +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/>.
"""General tests for Calendar.social
"""
import calsocial
from calsocial.models import db, User
from helpers import client
def test_register_page(client):
"""Test the registration page
"""
page = client.get('/register')
assert b'Register</button>' in page.data
def test_register_post_empty(client):
"""Test sending empty registration data
"""
page = client.post('/register', data={})
assert b'This field is required' in page.data
def test_register_invalid_email(client):
"""Test sending an invalid email address
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test',
'password': 'password',
'password_retype': 'password',
})
assert b'Invalid email address' in page.data
def test_register_password_mismatch(client):
"""Test sending different password for registration
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'something',
})
assert b'The two passwords must match' in page.data
def test_register(client):
"""Test user registration
"""
page = client.post('/register', data={
'username': 'test',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'password',
})
print(page.data)
assert page.status_code == 302
assert page.location == 'http://localhost/'
with calsocial.app.app_context():
user = User.query.one()
assert user.username == 'test'
assert user.email == 'test@example.com'
def test_register_existing_username(client):
"""Test registering an existing username
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com')
db.session.add(user)
db.session.commit()
page = client.post('/register', data={
'username': 'test',
'email': 'test2@example.com',
'password': 'password',
'password_retype': 'password',
})
assert b'This username is not available' in page.data
def test_register_existing_email(client):
"""Test registering an existing email address
"""
with calsocial.app.app_context():
user = User(username='test', email='test@example.com')
db.session.add(user)
db.session.commit()
page = client.post('/register', data={
'username': 'tester',
'email': 'test@example.com',
'password': 'password',
'password_retype': 'password',
})
assert b'This email address can not be used' in page.data