diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000..cbd5f5d --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory" : "assets/bower_components" +} diff --git a/.gitignore b/.gitignore index 156e009..6670a62 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,13 @@ settings_local.py* # PyCharm .idea + +# dev assets +studygroup/static +assets/bower_components/ + +# node modules +node_modules/ + +# test db +test.db diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..07bf38d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: python +env: + - MEETUP_OAUTH_SECRET=secret MEETUP_OAUTH_KEY=key +python: + - "2.7" +install: + - "pip install -r requirements.txt" +script: + - "python -m unittest discover" diff --git a/README.md b/README.md index 2174f4c..67746f4 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,31 @@ development machine so that it pretends to be $ python manage.py db upgrade -5. Start the server: +5. Install The javascript builder: + + Depending on your operating system install the npm package + (OSX) $ + * Brew + - $ brew install npm + * Mac Ports + - $ port install npm + + (Linux) + $ apt-get install npm + + $ npm install + $ bower install + $ gulp + +6. Start the server: $ python run_server.py -p 8080 -6. Visit the page in your browser using the URL http://localhost:8080. +7. Visit the page in your browser using the URL http://localhost:8080. You should see the Study Group page, and your server window should show URLs being served. -7. If you click the Sign In Now button, it should take you to meetup.com and +8. If you click the Sign In Now button, it should take you to meetup.com and ask you to authorize Boston Python Study Groups. diff --git a/studygroup/static/css/jumbotron-narrow.css b/assets/css/jumbotron-narrow.css similarity index 99% rename from studygroup/static/css/jumbotron-narrow.css rename to assets/css/jumbotron-narrow.css index 8379e16..ae7a83f 100644 --- a/studygroup/static/css/jumbotron-narrow.css +++ b/assets/css/jumbotron-narrow.css @@ -76,4 +76,4 @@ body { .jumbotron { border-bottom: 0; } -} \ No newline at end of file +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..321e712 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,19 @@ +$(function () { + $('[data-provide="datepicker"]').datetimepicker({ + icons: { + date: "fa fa-calendar", + up: "fa fa-arrow-up", + down: "fa fa-arrow-down" + }, + format: 'MM/DD/YYYY' + }); + $('[data-provide="datetimepicker"]').datetimepicker({ + icons: { + time: "fa fa-clock-o", + date: "fa fa-calendar", + up: "fa fa-arrow-up", + down: "fa fa-arrow-down" + }, + format: 'MM/DD/YYYY HH:mm' + }); +}); diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..c8768e2 --- /dev/null +++ b/bower.json @@ -0,0 +1,22 @@ +{ + "name": "studygroup", + "version": "0.0.0", + "license": "MIT", + "authors": [ + "Boston Python" + ], + "private": true, + "ignore": [ + "**/.*", + "node_modules", + "bower_components" + ], + "dependencies": { + "bootstrap": "~3.3.6", + "eonasdan-bootstrap-datetimepicker": "~4.17.0", + "font-awesome": "~4.5.0", + "jquery": "~2.2.0", + "lodash": "~4.2.1", + "moment": "~2.11.2" + } +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..e7a0599 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,103 @@ +var gulp = require("gulp"), + sourcemaps = require("gulp-sourcemaps"), + concat = require("gulp-concat"), + less = require('gulp-less'), + minifyCss = require('gulp-minify-css'), + uglify = require('gulp-uglifyjs'), + symlink = require('gulp-sym') + STATIC = 'studygroup/static'; + + +var LIBS_SCRIPTS = [ + 'assets/bower_components/jquery/dist/jquery.js', + 'assets/bower_components/bootstrap/dist/js/bootstrap.js', + 'assets/bower_components/lodash/lodash.min.js', + 'assets/bower_components/moment/min/moment.min.js', + 'assets/bower_components/eonasdan-bootstrap-datetimepicker/build/js/bootstrap-datetimepicker.min.js' +]; + +var LIBS_CSS = [ + 'assets/bower_components/font-awesome/css/font-awesome.css', + 'assets/bower_components/bootstrap/dist/css/bootstrap.css', + 'assets/bower_components/eonasdan-bootstrap-datetimepicker/build/css/bootstrap-datetimepicker.css', + 'assets/css/*.css', +]; + +var LIBS_FONTS = [ + 'assets/bower_components/font-awesome/fonts/**.*', + 'assets/bower_components/bootstrap/fonts/**.*', + 'assets/fonts/*/**.*', +]; + +gulp.task("fonts", function () { + return gulp.src(LIBS_FONTS) + .pipe(gulp.dest(STATIC + '/fonts')); +}); + +gulp.task("lib-css", function () { + return gulp.src(LIBS_CSS) + .pipe(gulp.dest(STATIC + '/dev/css')); +}); + +gulp.task("lib-js", function () { + return gulp.src(LIBS_SCRIPTS) + .pipe(gulp.dest(STATIC + '/dev/js')); +}); + +gulp.task("js", function () { + return gulp.src("./assets/js/**/*.js") + .pipe(sourcemaps.init()) + .pipe(concat("app.js")) + .pipe(sourcemaps.write(".")) + .pipe(gulp.dest(STATIC + '/dev/js')); +}); + +gulp.task("img", function () { + return gulp.src("./frontend/img/*") + .pipe(gulp.dest(STATIC + '/img')); +}); + +gulp.task("less", function () { + return gulp.src('./assets/less/**/*.less') + .pipe(sourcemaps.init()) + .pipe(less()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest(STATIC + '/dev/css')); +}); + +gulp.task('minify-css', function () { + return gulp.src(STATIC + '/dev/css/*.css') + .pipe(minifyCss({compatibility: 'ie8'})) + .pipe(concat('style.min.css')) + .pipe(gulp.dest(STATIC + '/dist')); +}); + +gulp.task('minify-jslibs', function () { + return gulp.src(LIBS_SCRIPTS) + .pipe(uglify()) + .pipe(concat('libs.min.js')) + .pipe(gulp.dest(STATIC + '/dist')); +}); + +gulp.task('minify-js', function () { + return gulp.src(STATIC + '/dev/js/app.js') + .pipe(uglify()) + .pipe(concat('app.min.js')) + .pipe(gulp.dest(STATIC + '/dist')); +}); + +gulp.task('dev-link', function () { + return gulp.src([STATIC+'/fonts']) + .pipe(symlink([STATIC+'/dev/fonts'])); +}); + +gulp.task("default", ["js", "less", "img", "fonts", "lib-css", "lib-js", "dev-link"]); + +gulp.task('watch', function () { + gulp.watch('./assets/less/**/*.less', ['less']); + gulp.watch('./assets/js/**/*.js', ['js']); + gulp.watch('./assets/img/*', ['img']); + gulp.watch(LIBS_FONTS, ['fonts']); + gulp.watch(LIBS_CSS, ['fonts']); + gulp.watch(LIBS_SCRIPTS, ['fonts']); +}); diff --git a/migrations/versions/16f11fc2c7e4_.py b/migrations/versions/16f11fc2c7e4_.py new file mode 100644 index 0000000..48d9ad6 --- /dev/null +++ b/migrations/versions/16f11fc2c7e4_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: 16f11fc2c7e4 +Revises: 9975799c2b8 +Create Date: 2016-02-02 19:24:50.744280 + +""" + +# revision identifiers, used by Alembic. +revision = '16f11fc2c7e4' +down_revision = '9975799c2b8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('group', sa.Column('start_date', sa.Date)) + op.add_column('group', sa.Column('start_time', sa.Time)) + op.add_column('group', sa.Column('active', sa.Boolean)) + + +def downgrade(): + op.drop_column('group', 'start_date') + op.drop_column('group', 'start_time') + op.drop_column('group', 'active') diff --git a/package.json b/package.json new file mode 100644 index 0000000..28b3c95 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "studygroup", + "version": "0.1.0", + "description": "The studygroup js build", + "devDependencies": {}, + "author": "Boston Python", + "dependencies": { + "bower": "^1.4.1", + "gulp": "^3.9.0", + "gulp-less": "^3.0.5", + "gulp-concat": "^2.6.0", + "gulp-sourcemaps": "^1.6.0", + "gulp-uglify": "^1.5.2", + "gulp-html-replace": "^1.5.5", + "gulp-minify-css": "^1.2.3", + "gulp-uglifyjs": "^0.6.2", + "gulp-sym": "^0.0.14 " + } +} diff --git a/requirements.txt b/requirements.txt index 5936510..b56d502 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -Flask-Bootstrap==3.1.0.1 Flask-Migrate==1.2.0 Flask-OAuthlib==0.4.2 Flask-SQLAlchemy==1.0 @@ -16,6 +15,10 @@ itsdangerous==0.23 oauthlib==0.6.1 psycopg2==2.5.2 wsgiref==0.1.2 +lxml==3.3.5 +requests==2.3.0 +cssselect==0.9.1 +mock==1.0.1 # Only for testing (separate out?) Flask-Testing==0.4 diff --git a/settings.py b/settings.py index 93a535c..43d879d 100644 --- a/settings.py +++ b/settings.py @@ -7,9 +7,10 @@ SQLALCHEMY_DATABASE_URI = get('SQLALCHEMY_DATABASE_URI') DEFAULT_MAX_MEMBERS = 10 +WEBPACK_MANIFEST_PATH = "/Users/mmilkin/code/studygroup/manifest.json" # import local config to override global config try: from settings_local import * except ImportError: - pass \ No newline at end of file + pass diff --git a/studygroup/application.py b/studygroup/application.py index d2a87d7..ea05f70 100644 --- a/studygroup/application.py +++ b/studygroup/application.py @@ -1,6 +1,5 @@ from flask import Flask from flask_oauthlib.client import OAuth -from flask_bootstrap import Bootstrap from flask.ext.migrate import Migrate from flask.ext.sqlalchemy import SQLAlchemy @@ -11,17 +10,18 @@ oauth = OAuth() migrate = Migrate() + def create_app(debug=True): from views import studygroup - + from groups import groups app = Flask(__name__) app.debug = debug app.secret_key = 'development' app.config.from_object(settings) app.register_blueprint(studygroup) + app.register_blueprint(groups) - Bootstrap(app) db.init_app(app) oauth.init_app(app) migrate.init_app(app, db) diff --git a/studygroup/auth.py b/studygroup/auth.py index 5641f6a..3d438a5 100644 --- a/studygroup/auth.py +++ b/studygroup/auth.py @@ -5,7 +5,7 @@ def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): - if g.user is None: + if getattr(g, 'user', None) is None: return redirect(url_for('studygroup.login', next=request.url)) return f(*args, **kwargs) return decorated_function diff --git a/studygroup/exceptions.py b/studygroup/exceptions.py index c306f13..7f97a5b 100644 --- a/studygroup/exceptions.py +++ b/studygroup/exceptions.py @@ -1,10 +1,21 @@ - class ModelNotFoundException(Exception): pass -class GroupFullException(Exception): +class FormValidationException(Exception): + pass + +class UnAuthorizedException(FormValidationException): + def __init__(self, member_id): + message = "UnAuthorized action for member with Id: %s " % (member_id) + super(UnAuthorizedException, self).__init__(message) + +class MembershipException(FormValidationException): + def __init__(self, member_id, group_id): + message = "Member {%s} is already part of group: {%s} " % (member_id, group_id) + super(MembershipException, self).__init__(message) +class GroupFullException(FormValidationException): def __init__(self, group): message = "Group: %s Id: %s" % (group.name, group.id) - super(GroupFullException, self).__init__(message) \ No newline at end of file + super(GroupFullException, self).__init__(message) diff --git a/studygroup/forms.py b/studygroup/forms.py index d491683..6e5e410 100644 --- a/studygroup/forms.py +++ b/studygroup/forms.py @@ -1,24 +1,30 @@ from flask import g from flask_wtf import Form -from wtforms import TextField, TextAreaField, IntegerField +from wtforms import TextField, TextAreaField, DateField, IntegerField, ValidationError, DateTimeField, BooleanField from wtforms.validators import DataRequired from wtforms.widgets import HiddenInput from .application import db from .models import Group, Membership, ROLE_GROUP_LEADER -from .exceptions import GroupFullException +from .exceptions import GroupFullException, MembershipException +from studygroup.messaging import send_join_notification class GroupForm(Form): name = TextField('Name', validators=[DataRequired()]) description = TextAreaField('Description', validators=[DataRequired()]) max_members = TextField('Max Members', validators=[DataRequired()]) + start_date = DateTimeField('Starts At', format='%m/%d/%Y %H:%M', validators=[DataRequired()]) + active = BooleanField('Active?', default=False) def save(self): group = Group( name=self.name.data, description=self.description.data, - max_members=self.max_members.data + max_members=self.max_members.data, + start_date=self.start_date.data.date(), + start_time=self.start_date.data.time(), + active=1 if self.active else 0 ) membership = Membership( @@ -35,20 +41,62 @@ def save(self): class MembershipForm(Form): - user_id = IntegerField(widget=HiddenInput(), validators=[DataRequired()]) group_id = IntegerField(widget=HiddenInput(), validators=[DataRequired()]) + def __init__(self, user_id, *args, **kwargs): + self.user_id = user_id + super(MembershipForm, self).__init__(*args, **kwargs) + + def validate_group_id(form, field): + user_id = form._get_user() + group = Group.by_id_with_memberships(field.data) + form._validate_full_group(group) + form._validate_existing_member(group.id, user_id) + + def _validate_existing_member(self, group_id, user_id): + member = Membership.by_group_and_user_ids(group_id, user_id) + if member: + raise ValidationError(u'This member is already in this group') + + def _validate_full_group(self, group): + if group.is_full(): + raise ValidationError(u'This group is full') + + def _get_user(self): + user_id = getattr(self, 'user_id', None) + + if user_id is None: + raise ValidationError(u'Invalid user') + return user_id + def save(self): group = Group.by_id_with_memberships(self.group_id.data) + if group.is_full(): raise GroupFullException(group) + user_id = self._get_user() + + member = Membership.by_group_and_user_ids(group.id, user_id) + + if member: + raise MembershipException(group.id, user_id) + membership = Membership( - user_id=self.user_id.data, - group_id=self.group_id.data + user_id=user_id, + group_id=group.id ) db.session.add(membership) db.session.commit() + leader_memberships = Membership.by_group_leader(group.id) + + if leader_memberships: + for leader_membership in leader_memberships: + send_join_notification( + leader_membership.user, + membership.user, + group + ) return membership diff --git a/studygroup/groups.py b/studygroup/groups.py new file mode 100644 index 0000000..b874aab --- /dev/null +++ b/studygroup/groups.py @@ -0,0 +1,81 @@ +from flask import (g, request, redirect, render_template, + session, url_for, Blueprint) + +from .models import Group, Membership, User +from .auth import login_required +from .forms import GroupForm, MembershipForm +from studygroup.exceptions import FormValidationException + + +groups = Blueprint("group", __name__, static_folder='static') + +@groups.before_request +def load_user(): + user_id = session.get('user_id') + if user_id: + g.user = User.query.filter_by(id=user_id).first() + else: + g.user = None + +def _show_group_post(group): + form = MembershipForm(session.get('user_id')) + if form.validate_on_submit(): + try: + form.save() + except FormValidationException as e: + form.form_errors = e.message + return render_show_group(group, form) + + +def render_show_group(group, form=None): + if not form: + user_id = session.get('user_id') + membership = Membership( + user_id=user_id, + group_id=group.id + ) + form = MembershipForm(user_id, obj=membership) + + return render_template('show_group.html', form=form) + + +@groups.route('/join_group', methods=('POST',)) +def join_group(): + pass + + +def render_show_group(group, form=None): + if not form: + user_id = session.get('user_id') + membership = Membership( + user_id=user_id, + group_id=group.id + ) + form = MembershipForm(user_id, obj=membership) + + return render_template('show_group.html', form=form) + + +@groups.route('/group/new', methods=('GET', 'POST')) +def new_group(): + form = GroupForm() + if form.validate_on_submit(): + group = form.save() + return redirect(url_for('.show_group', id=group.id)) + return render_template('new_group.html', form=form) + + +@groups.route('/groups') +@login_required +def show_groups(): + g.groups = Group.all_with_memberships() + return render_template('groups.html') + +@groups.route('/group/', methods=('POST', 'GET')) +@login_required +def show_group(id): + g.group = Group.query.filter_by(id=id).first() + if request.method == 'POST': + return _show_group_post(id) + else: + return render_show_group(g.group) diff --git a/studygroup/messaging.py b/studygroup/messaging.py new file mode 100644 index 0000000..87959b0 --- /dev/null +++ b/studygroup/messaging.py @@ -0,0 +1,26 @@ +from flask import render_template +from .application import meetup + + +def send_message(subject, recipient, text_body): + response = meetup.post( + '2/message', + data={ + 'subject': subject, + 'message': text_body, + 'member_id': long(recipient.meetup_member_id) + }, + ) + return response + + +def send_join_notification(recipient, user, group): + send_message( + 'BostonPython: {0} is joining you at {1}!'.format(user.full_name, group.name), + recipient, + render_template( + 'email/joined_group.txt', + user=user, + group=group + ) + ) diff --git a/studygroup/models.py b/studygroup/models.py index 63cdfa8..d2352ce 100644 --- a/studygroup/models.py +++ b/studygroup/models.py @@ -1,6 +1,8 @@ """ Data models for StudyGroups """ +import datetime +from sqlalchemy import UniqueConstraint from .application import db import settings @@ -8,6 +10,7 @@ ROLE_MEMBER = 1 ROLE_GROUP_LEADER = 10 + class User(db.Model): id = db.Column(db.Integer, primary_key=True) meetup_member_id = db.Column(db.String, unique=True) @@ -23,19 +26,60 @@ class Membership(db.Model): group_id = db.Column(db.Integer, db.ForeignKey('group.id')) role = db.Column(db.Integer, nullable=False, default=ROLE_MEMBER) + __table_args__ = ( + UniqueConstraint('user_id', 'group_id', name='_group_user_uc'), + ) + + @classmethod + def by_group_and_user_ids(cls, group_id, user_id): + return Membership.query.filter_by( + group_id=group_id, + user_id=user_id + ).first() + + @classmethod + def by_group_leader(cls, group_id): + return Membership.query.filter_by( + group_id=group_id, + role=ROLE_GROUP_LEADER + ).all() + class Group(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, unique=True) description = db.Column(db.String) - max_members = db.Column(db.Integer, nullable=False, - default=settings.DEFAULT_MAX_MEMBERS) + max_members = db.Column( + db.Integer, nullable=False, + default=settings.DEFAULT_MAX_MEMBERS + ) + + start_date = db.Column( + db.Date, + nullable=False + ) + + start_time = db.Column( + db.Time, nullable=False, + default=datetime.time(hour=18) + ) + + active = db.Column( + db.Boolean, + nullable=False, + default=False + ) memberships = db.relationship("Membership", backref="group") @classmethod def all_with_memberships(cls): - return Group.query.options(db.joinedload(Group.memberships)).all() + return Group.query.filter_by(active=1)\ + .options(db.joinedload(Group.memberships)).all() + + @classmethod + def all_actives(cls): + return Group.query.filter_by(active=1).all() @classmethod def by_id_with_memberships(cls, id): @@ -44,3 +88,7 @@ def by_id_with_memberships(cls, id): def is_full(self): return len(self.memberships) >= self.max_members + + @property + def empty_seats(self): + return self.max_members - len(self.memberships) diff --git a/studygroup/templates/base.html b/studygroup/templates/base.html new file mode 100644 index 0000000..ac5e02f --- /dev/null +++ b/studygroup/templates/base.html @@ -0,0 +1,67 @@ +{% block doc -%} + + +{%- block html %} + + {%- block head %} + {% block title %}{% endblock title %} + + {%- block metas %} + + {%- endblock metas %} + + {%- block fonts %} + {%- endblock fonts %} + + {%- block styles %} + + + + + {%- endblock styles %} + + {%- block scripts %} + + + + + + {% endblock scripts -%} + + {%- endblock head %} + + + {% block body -%} +
+ {% block navbar -%} +
+ +

Studygroup

+
+ {%- endblock navbar %} + + {% block content -%} + + {%- endblock content %} + + {% block body_scripts %} + {%- endblock body_scripts %} + + {% block footer %} + + {% endblock footer %} +
+ {%- endblock body %} + +{%- endblock html %} + +{% endblock doc -%} diff --git a/studygroup/templates/email/joined_group.txt b/studygroup/templates/email/joined_group.txt new file mode 100644 index 0000000..52c510b --- /dev/null +++ b/studygroup/templates/email/joined_group.txt @@ -0,0 +1,3 @@ +Congratulations {{ user.full_name }} joined group {{ group.name }}, + +You now have {{ group.memberships | length }} users going! diff --git a/studygroup/templates/groups.html b/studygroup/templates/groups.html index b4db375..d3ae498 100644 --- a/studygroup/templates/groups.html +++ b/studygroup/templates/groups.html @@ -1,50 +1,25 @@ -{% extends "bootstrap/base.html" %} +{% extends "base.html" %} {% block title %}Studygroup | Groups{% endblock %} -{% block styles %} -{{super()}} - -{% endblock %} - {% block content %} -
-
- -

Studygroup

-
- - - -
-
- {% for group in g.groups %} -

{{ group.name }}

-

Members: {{ group.memberships | length }}

-

{{ group.description }}

- {% else %} -

No groups

- Propose a Study Group - {% endfor %} -
-
+ - {% endblock %} diff --git a/studygroup/templates/index.html b/studygroup/templates/index.html index d2d2041..18356a0 100644 --- a/studygroup/templates/index.html +++ b/studygroup/templates/index.html @@ -1,86 +1,33 @@ -{% extends "bootstrap/base.html" %} +{% extends "base.html" %} {% block title %}Studygroup | a Boston Python Project{% endblock %} -{% block styles %} -{{super()}} - -{% endblock %} - {% block content %} -
-
- -

Studygroup

-
- -
-

Studygroup

- -

- Create and manage small study groups inside Boston Python -

- - {% if not g.user %} -

- Sign In Now -

- {% else %} -

- Welcome, {{ g.user.full_name }} -

- {% endif %} +
+

Studygroup

+ +

+ Create and manage small study groups inside Boston Python +

+ + {% if not g.user %} +

+ Sign In Now +

+ {% else %} +

+ Welcome, {{ g.user.full_name }} +

+ {% endif %} +
+
+ {% for group in groups %} +
+

{{ group.name }}

+

{{ group.description }}

+
{{ group.empty_seats }} seats of {{ group.max_members}} left
- -
-
-

Subheading

- -

Donec id elit non mi porta gravida at eget metus. Maecenas - faucibus mollis interdum.

- -

Subheading

- -

Morbi leo risus, porta ac consectetur ac, vestibulum at eros. - Cras mattis consectetur purus sit amet fermentum.

- -

Subheading

- -

Maecenas sed diam eget risus varius blandit sit amet non - magna.

-
- -
-

Subheading

- -

Donec id elit non mi porta gravida at eget metus. Maecenas - faucibus mollis interdum.

- -

Subheading

- -

Morbi leo risus, porta ac consectetur ac, vestibulum at eros. - Cras mattis consectetur purus sit amet fermentum.

- -

Subheading

- -

Maecenas sed diam eget risus varius blandit sit amet non - magna.

-
-
- - - -
- + {% endfor %} +
{% endblock %} diff --git a/studygroup/templates/members.html b/studygroup/templates/members.html index 4318d03..96c07fe 100644 --- a/studygroup/templates/members.html +++ b/studygroup/templates/members.html @@ -1,48 +1,22 @@ -{% extends "bootstrap/base.html" %} +{% extends "base.html" %} {% block title %}Studygroup | Members{% endblock %} -{% block styles %} -{{super()}} - -{% endblock %} - {% block content %} -
-
- -

Studygroup

-
- - {% endblock %} diff --git a/studygroup/templates/new_group.html b/studygroup/templates/new_group.html index 800cd7b..40bef16 100644 --- a/studygroup/templates/new_group.html +++ b/studygroup/templates/new_group.html @@ -1,48 +1,23 @@ -{% extends "bootstrap/base.html" %} -{% import "bootstrap/wtf.html" as wtf %} +{% extends "base.html" %} +{% import "wtf_macro.html" as wtf %} {% block title %}Studygroup | Groups{% endblock %} -{% block styles %} -{{super()}} - -{% endblock %} - {% block content %} -
-
- -

Studygroup

-
- - + -
-
-
- {{ form.hidden_tag() }} - {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.name) }} - {{ wtf.form_field(form.description) }} - {{ wtf.form_field(form.max_members) }} - -
-
+
+
+
+ {{ form.hidden_tag() }} + {{ wtf.render_field(form.name) }} + {{ wtf.render_field(form.description) }} + {{ wtf.render_field(form.max_members) }} + {{ wtf.render_field(form.start_date) }} + {{ wtf.render_field(form.active) }} + +
- - - -
- +
{% endblock %} diff --git a/studygroup/templates/show_group.html b/studygroup/templates/show_group.html index d2babc8..5e59f5b 100644 --- a/studygroup/templates/show_group.html +++ b/studygroup/templates/show_group.html @@ -1,38 +1,34 @@ -{% extends "bootstrap/base.html" %} +{% extends "base.html" %} +{% import "wtf_macro.html" as wtf %} {% block title %}Studygroup | Groups{% endblock %} -{% block styles %} -{{super()}} - -{% endblock %} - {% block content %} -
-
- -

Studygroup

-
- - - -
- {{ g.group.description }} -
+ - +
+ {{ g.group.description }} +
+
+

Members: {{ g.group.memberships | length }}

+
+ {% if form.errors %} +
    + {% for field_name, field_errors in form.errors|dictsort if field_errors %} + {% for error in field_errors %} +
  • {{ form[field_name].label }}: {{ error }}
  • + {% endfor %} + {% endfor %} +
+ {% endif %} -
+
+
+ {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + +
+
{% endblock %} diff --git a/studygroup/templates/wtf_macro.html b/studygroup/templates/wtf_macro.html new file mode 100644 index 0000000..d3115f0 --- /dev/null +++ b/studygroup/templates/wtf_macro.html @@ -0,0 +1,67 @@ +{% macro render_field(field) -%} + {% set with_label = kwargs.pop('with_label', False) %} + {% set placeholder = '' %} + {% if not with_label %} + {% set placeholder = field.label.text %} + {% endif %} +
+ {% if with_label %} + + {% endif %} + + {% set class_ = kwargs.pop('class_', '') %} + {% if field.flags.required %} + {% set class_ = class_ + ' required' %} + {% endif %} + + {% if field.type == 'BooleanField' %} +
+ +
+ + {% else %} + + {% if field.type in ('TextField', 'TextAreaField', 'PasswordField', 'DateField', 'DateTimeField') %} + {% set class_ = class_ + ' input-xlarge form-control' %} + {% elif field.type == 'FileField' %} + {% set class_ = class_ + ' input-file form-control' %} + {% endif %} + + {% if field.type == 'SelectField' %} + {{ field(class_=class_, **kwargs) }} + {% elif field.type in ['DateField', 'DateTimeField'] %} +
+ {{ field(class_=class_, placeholder=placeholder, **kwargs) }} + + + + +
+ {% else %} + {{ field(class_=class_, placeholder=placeholder, **kwargs) }} + {% endif %} + + {% endif %} + {% if field.errors %} + {{ field.errors|join(', ') }} + {% endif %} + {% if field.description %} +

{{ field.description|safe }}

+ {% endif %} +
+{%- endmacro %} + +{% macro form_errors(form, hiddens=True) %} + {%- if form.errors %} + {%- for fieldname, errors in form.errors.items() %} + {%- for error in errors %} +

{{error}}

+ {%- endfor %} + {%- endfor %} + {%- endif %} +{%- endmacro %} diff --git a/studygroup/views.py b/studygroup/views.py index 3060ac1..d647774 100644 --- a/studygroup/views.py +++ b/studygroup/views.py @@ -4,10 +4,11 @@ from .models import User, Group from .application import db, meetup from .auth import login_required -from .forms import GroupForm +from .messaging import send_message studygroup = Blueprint("studygroup", __name__, static_folder='static') + @studygroup.before_request def load_user(): user_id = session.get('user_id') @@ -19,30 +20,9 @@ def load_user(): @studygroup.route('/') def index(): - return render_template('index.html') - -@studygroup.route('/groups') -@login_required -def show_groups(): - g.groups = Group.all_with_memberships() - return render_template('groups.html') - -@studygroup.route('/group/') -def show_group(id): - g.group = Group.query.filter_by(id=id).first() - return render_template('show_group.html') - -@studygroup.route('/group/new', methods=('GET', 'POST')) -def new_group(): - form = GroupForm() - if form.validate_on_submit(): - group = form.save() - return redirect(url_for('.show_group', id=group.id)) - return render_template('new_group.html', form=form) - -@studygroup.route('/join_group', methods=('POST',)) -def join_group(): - pass + return render_template( + 'index.html', groups=Group.all_actives() + ) @studygroup.route('/members') @@ -81,17 +61,16 @@ def send_message(member_id): member = meetup.get('2/member/%s' % member_id) return render_template("send_message.html", member=member.data) elif request.method == 'POST': - response = meetup.post( - '2/message', - data={ - 'subject': request.form['subject'], - 'message': request.form['message'], - 'member_id': request.form['member_id'] - }) + response = send_message( + request.form['subject'], + request.form['member_id'], + request.form['message'] + ) return jsonify(response.data) else: return "Invalid Request", 500 + @studygroup.route('/boom') def boom(): raise Exception('BOOM') @@ -111,7 +90,7 @@ def logout(): @studygroup.route('/login/authorized') @meetup.authorized_handler def authorized(resp): - if resp is None: + if resp is None or not isinstance(resp, dict): return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], request.args['error_description'] @@ -130,7 +109,6 @@ def authorized(resp): ) db.session.add(user) db.session.commit() - session['user_id'] = user.id return redirect(url_for('.index')) diff --git a/tests/test_app.py b/tests/test_app.py index ca2ab68..4be537d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,5 @@ +import datetime from flask import url_for - from .tools import StudyGroupTestCase from studygroup.models import Group @@ -21,10 +21,11 @@ def test_logged_in(self): class GroupCreationTest(StudyGroupTestCase): + def test_make_a_group(self): self.login(self.alice_id) # Open the new group page. - resp = self.client.get(url_for('studygroup.new_group')) + resp = self.client.get(url_for('group.new_group')) self.assert200(resp) # Submit the new group form. @@ -32,13 +33,18 @@ def test_make_a_group(self): 'description': "A group!", 'max_members': "10", 'name': "The Group", + 'active': True, + 'start_date': '12/25/2002 14:22' } - resp = self.client.post(url_for('studygroup.new_group'), data=data, follow_redirects=False) + resp = self.client.post(url_for('group.new_group'), data=data, follow_redirects=False) # It should take us to look at the new group. - self.assert_redirects(resp, url_for('studygroup.show_group', id=1)) + self.assert_redirects(resp, url_for('group.show_group', id=1)) # The new group should have the right data. g1 = Group.query.filter_by(id=1).first() self.assertEqual(g1.name, "The Group") self.assertEqual(g1.description, "A group!") self.assertEqual(g1.max_members, 10) + self.assertEqual(g1.start_date, datetime.date(2002, 12, 25)) + self.assertEqual(g1.start_time, datetime.time(14,22)) + self.assertEqual(g1.active, 1) diff --git a/tests/test_membership.py b/tests/test_membership.py new file mode 100644 index 0000000..0a7f2b5 --- /dev/null +++ b/tests/test_membership.py @@ -0,0 +1,281 @@ +from flask import url_for +from wtforms import ValidationError +from studygroup.exceptions import GroupFullException, MembershipException +from studygroup.forms import MembershipForm +from studygroup.application import db +from studygroup.models import Group, Membership, User, ROLE_GROUP_LEADER +from tests.tools import StudyGroupTestCase +import mock + + +class GroupBaseTestCase(StudyGroupTestCase): + + def setUp(self): + super(GroupBaseTestCase, self).setUp() + self.group = Group( + name='group name', + description='description' + ) + + self.leader = User( + meetup_member_id=1001, + full_name='Leader', + is_admin=False + ) + + self.leader_membership = Membership( + user=self.leader, + group=self.group, + role=ROLE_GROUP_LEADER + ) + + db.session.add(self.group) + db.session.add(self.leader) + db.session.add(self.leader_membership) + db.session.commit() + + +class MembershipPageTest(GroupBaseTestCase): + + def test_show_group_auth(self): + resp = self.client.get(url_for('group.show_group', id=self.group.id)) + self.assert302(resp) + + def test_show_group_auth(self): + self.login(self.alice_id) + resp = self.client.get(url_for('group.show_group', id=self.group.id)) + self.assert200(resp) + self.assertIn('

{}

'.format(self.group.name), resp.data) + + def test_show_group_post_auth(self): + resp = self.client.post(url_for('group.show_group', id=self.group.id)) + self.assert302(resp) + + def test_show_group_post(self): + self.login(self.alice_id) + + data = { + 'group_id': self.group.id, + } + + member = Membership.by_group_and_user_ids(self.group.id, self.alice_id) + self.assertIsNone(member) + + resp = self.client.post( + url_for('group.show_group', id=self.group.id), + data=data + ) + + member = Membership.by_group_and_user_ids(self.group.id, self.alice_id) + self.assertIsNotNone(member) + self.assert200(resp) + self.assertIn('Members: 2', resp.data) + + def test_bad_group(self): + self.login(self.alice_id) + data = {} + + resp = self.client.post( + url_for('group.show_group', id=self.group.id), + data=data + ) + self.assert200(resp) + self.assertIn('This field is required.', resp.data) + + def test_existing_membership(self): + self.login(self.alice_id) + membership = Membership( + user_id=self.alice_id, + group_id=self.group.id + ) + db.session.add(membership) + db.session.commit() + + data = { + 'group_id': self.group.id, + } + + resp = self.client.post( + url_for('group.show_group', id=self.group.id), + data=data + ) + self.assert200(resp) + self.assertIn('This member is already in this group', resp.data) + + +class MembershipFormTest(StudyGroupTestCase): + + @mock.patch('studygroup.forms.Membership.by_group_and_user_ids') + def test_validate_existing_member(self, by_group_and_user_ids): + by_group_and_user_ids.return_value = [23] + form = MembershipForm(23) + with self.assertRaisesRegexp(ValidationError, u'This member is already in this group'): + form._validate_existing_member(23, 33) + + by_group_and_user_ids.assert_called_with(23, 33) + + def test_validate_full_group(self): + group = mock.Mock(name='group', spec=['is_full']) + group.is_full.return_value = True + form = MembershipForm(23) + with self.assertRaisesRegexp(ValidationError, u'This group is full'): + form._validate_full_group(group) + + self.assertTrue(group.is_full.called) + + def test_get_user(self): + form = MembershipForm(23) + self.assertEqual(form._get_user(), 23) + + def test_no_user(self): + form = MembershipForm(23) + form.user_id = None + with self.assertRaisesRegexp(ValidationError, u'Invalid user'): + form._get_user() + + @mock.patch('studygroup.forms.Group.by_id_with_memberships') + def test_validate_group_id(self, by_id_with_memberships): + group = mock.Mock(name='group', id=7) + by_id_with_memberships.return_value = group + + form = MembershipForm(23) + form._validate_full_group = mock.Mock() + form._validate_existing_member = mock.Mock() + field = mock.Mock(name='field', spec=['data']) + field.data = 'FormData' + + form.validate_group_id(field) + + form._validate_full_group.assert_called_with(group) + form._validate_existing_member.assert_called_with(7, 23) + by_id_with_memberships.assert_called_with('FormData') + + + @mock.patch('studygroup.forms.Membership') + @mock.patch('studygroup.forms.Group.by_id_with_memberships') + @mock.patch('studygroup.forms.db.session') + @mock.patch('studygroup.forms.send_join_notification') + def test_save_no_leader(self, send_join_notification, session, by_id_with_memberships, membership): + user = mock.Mock(name='user') + new_membership = mock.Mock(name='new membership', user=user) + membership.return_value = new_membership + + group = mock.Mock(name='group', id=7) + group.is_full.return_value = False + by_id_with_memberships.return_value = group + membership.by_group_and_user_ids.return_value = None + + membership.by_group_leader.return_value = None + form = MembershipForm(23) + form.group_id.data = 88 + + form.save() + + by_id_with_memberships.assert_called_with(88) + membership.by_group_and_user_ids.assert_called_with(7, 23) + + self.assertTrue(session.add.called) + self.assertTrue(session.commit.called) + self.assertFalse(send_join_notification.called) + + @mock.patch('studygroup.forms.Membership') + @mock.patch('studygroup.forms.Group.by_id_with_memberships') + @mock.patch('studygroup.forms.db.session') + @mock.patch('studygroup.forms.send_join_notification') + def test_save(self, send_join_notification, session, by_id_with_memberships, membership): + + user = mock.Mock(name='user') + new_membership = mock.Mock(name='new membership', user=user) + membership.return_value = new_membership + + group = mock.Mock(name='group', id=7) + group.is_full.return_value = False + by_id_with_memberships.return_value = group + membership.by_group_and_user_ids.return_value = None + + leader = mock.Mock() + leader_membership = mock.Mock(name='leader', user=leader) + + leader2 = mock.Mock() + leader_membership2 = mock.Mock(name='leader', user=leader2) + + membership.by_group_leader.return_value = [leader_membership, leader_membership2] + form = MembershipForm(23) + form.group_id.data = 88 + + form.save() + + by_id_with_memberships.assert_called_with(88) + membership.by_group_and_user_ids.assert_called_with(7, 23) + + self.assertTrue(session.add.called) + self.assertTrue(session.commit.called) + + calls = [ + mock.call(leader, user, group), + mock.call(leader2, user, group) + ] + send_join_notification.assert_has_calls(calls, any_order=True) + + @mock.patch('studygroup.forms.Membership.by_group_and_user_ids') + @mock.patch('studygroup.forms.Group.by_id_with_memberships') + @mock.patch('studygroup.forms.db.session') + @mock.patch('studygroup.forms.send_join_notification') + def test_save_full_group(self, send_join_notification, session, by_id_with_memberships, by_group_and_user_ids): + group = mock.Mock(name='group', id=7) + group.is_full.return_value = True + by_id_with_memberships.return_value = group + form = MembershipForm(23) + with self.assertRaises(GroupFullException): + form.save() + + self.assertFalse(session.add.called) + self.assertFalse(session.commit.called) + self.assertFalse(send_join_notification.called) + + @mock.patch('studygroup.forms.Membership.by_group_and_user_ids') + @mock.patch('studygroup.forms.Group.by_id_with_memberships') + @mock.patch('studygroup.forms.db.session') + @mock.patch('studygroup.forms.send_join_notification') + def test_save_member(self, send_join_notification, session, by_id_with_memberships, by_group_and_user_ids): + group = mock.Mock(name='group', id=7) + group.is_full.return_value = False + by_id_with_memberships.return_value = group + by_group_and_user_ids.return_value = [23] + form = MembershipForm(23) + with self.assertRaises(MembershipException): + form.save() + + self.assertFalse(session.add.called) + self.assertFalse(session.commit.called) + self.assertFalse(send_join_notification.called) + + +class MembershipModelTest(GroupBaseTestCase): + + def test_group_user(self): + membership = Membership( + user_id=self.alice_id, + group_id=self.group.id + ) + + db.session.add(membership) + db.session.commit() + + actual = Membership.by_group_and_user_ids(self.group.id, self.alice_id) + self.assertEqual(membership, actual) + + def test_group_user_not_found(self): + actual = Membership.by_group_and_user_ids(self.group.id, self.alice_id,) + self.assertIsNone(actual) + + def test_by_group_leader_no_leader(self): + db.session.delete(self.leader_membership) + db.session.commit() + + actual = Membership.by_group_leader(self.group.id) + self.assertEqual(actual, []) + + def test_by_group_leader(self): + actual = Membership.by_group_leader(self.group.id) + self.assertEqual(actual, [self.leader_membership]) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2e6bc44 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,82 @@ +import datetime +from studygroup.application import db +from tests.tools import StudyGroupTestCase + +from studygroup.models import Group, Membership, ROLE_GROUP_LEADER, User + +class GroupModelTest(StudyGroupTestCase): + + def test_all_active(self): + group1 = self.create_group( + 'DeadPool vs Wolverine', + 'Lets figure out who is the most undead?', + max_members=10, + start_date=datetime.date(2012, 10, 10), + start_time=datetime.time(14, 22), + active=True + ) + group2 = self.create_group( + 'Flash vs Quicksilver', + 'Lets figure out who is the fastest?', + max_members=10, + start_date=datetime.date(2013, 10, 10), + start_time=datetime.time(14, 22), + active=False + ) + + membership1 = Membership( + user_id=self.alice_id, + group=group1, + role=10 + ) + + membership2 = Membership( + user_id=self.alice_id, + group=group2, + role=10 + ) + db.session.add(membership1) + db.session.add(membership2) + db.session.commit() + self.assertEqual( + [gr.id for gr in Group.all_actives()], + [group1.id] + ) + + def test_with_full(self): + group = self.create_group( + 'DeadPool vs Wolverine', + 'Lets figure out who is the most undead?', + max_members=1, + start_date=datetime.date(2012, 10, 10), + start_time=datetime.time(14, 22), + active=True + ) + membership = Membership( + user_id=self.alice_id, + group=group, + role=10 + ) + db.session.add(membership) + db.session.commit() + group = Group.by_id_with_memberships(group.id) + self.assertTrue(group.is_full()) + + def test_empty_seats(self): + group = self.create_group( + 'DeadPool vs Wolverine', + 'Lets figure out who is the most undead?', + max_members=3, + start_date=datetime.date(2012, 10, 10), + start_time=datetime.time(14, 22), + active=True + ) + membership = Membership( + user_id=self.alice_id, + group=group, + role=10 + ) + db.session.add(membership) + db.session.commit() + group = Group.by_id_with_memberships(group.id) + self.assertEqual(group.empty_seats, 2) diff --git a/tests/tools.py b/tests/tools.py index 0675b01..7bb32a4 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -3,12 +3,14 @@ """ import os.path +import datetime import flask.ext.migrate from flask.ext.testing import TestCase +import mock from studygroup.application import create_app, db -from studygroup.models import User +from studygroup.models import User, Group DB_PATH = 'sqlite:///' + os.path.dirname(__file__) + '/../test.db' @@ -19,12 +21,24 @@ class StudyGroupTestCase(TestCase): Base class for all StudyGroup tests. """ def setUp(self): + # Remove if something is created + db.session.remove() + db.drop_all() + # If a test needs to send messages the class must define send_messages True + # Otherwise no test should send messages. + if not getattr(self, 'send_messages', False): + self.patcher = mock.patch('studygroup.messaging.meetup.post', spec=True) + self.patcher.start() + flask.ext.migrate.upgrade() self.alice_id = self.create_user(full_name='Alice B.') self.admin_id = self.create_user(full_name='Bob Admin', is_admin=True) def tearDown(self): db.session.execute('DROP TABLE alembic_version') + if getattr(self, 'send_messages', False): + self.patcher.stop() + db.session.remove() db.drop_all() @@ -52,9 +66,31 @@ def create_user(self, **kwargs): db.session.commit() return user.id + def create_group( + self, + name, + description, + max_members=10, + start_date=datetime.date.today(), + start_time=datetime.datetime.now().time(), + active = True): + group = Group( + name=name, + description=description, + max_members=max_members, + start_date=start_date, + start_time=start_time, + active=active + ) + db.session.add(group) + return group + def login(self, user_id): """ Log in a user by user id. """ with self.client.session_transaction() as session: session['user_id'] = user_id + + def assert302(self, response): + self.assertStatus(response, 302)