├── graygram ├── views │ ├── api │ │ ├── __init__.py │ │ ├── index.py │ │ ├── logout.py │ │ ├── photos.py │ │ ├── me.py │ │ ├── login.py │ │ ├── join.py │ │ ├── feed.py │ │ └── posts.py │ ├── __init__.py │ ├── letsencrypt.py │ └── photos.py ├── renderers │ ├── __init__.py │ └── json_renderer.py ├── crypto.py ├── models │ ├── __init__.py │ ├── post_like.py │ ├── credential.py │ ├── photo.py │ ├── user.py │ └── post.py ├── orm │ └── __init__.py ├── paging.py ├── __init__.py ├── cache.py ├── s3.py ├── photo_uploader.py ├── app.py └── exceptions.py ├── migrations ├── README ├── script.py.mako ├── alembic.ini ├── versions │ ├── 8dd6149e6a9c_add_post_like.py │ ├── 3f0436a7bc65_create_first_index.py │ └── e851b34d79b8_init.py └── env.py ├── tests ├── images │ ├── ironman.png │ └── tower_eiffel.jpg ├── fixtures │ ├── __init__.py │ ├── user_fixtures.py │ └── post_fixtures.py ├── views │ ├── test_join.py │ ├── test_login.py │ ├── test_me.py │ └── test_posts.py ├── __init__.py ├── test_cache.py ├── clients.py └── conftest.py ├── docs ├── restapi │ ├── feed.rst │ ├── users.rst │ ├── auth.rst │ ├── overview.rst │ └── posts.rst ├── restapi.rst ├── index.rst ├── Makefile └── conf.py ├── .travis.yml ├── Procfile ├── .gitignore ├── config ├── doc.cfg ├── test.cfg └── prod.cfg ├── manage.py ├── setup.py ├── LICENSE └── README.md /graygram/views/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /graygram/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from json_renderer import render_json 4 | -------------------------------------------------------------------------------- /tests/images/ironman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devxoul/graygram-web/HEAD/tests/images/ironman.png -------------------------------------------------------------------------------- /graygram/crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_bcrypt import Bcrypt 4 | 5 | bcrypt = Bcrypt() 6 | -------------------------------------------------------------------------------- /tests/images/tower_eiffel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devxoul/graygram-web/HEAD/tests/images/tower_eiffel.jpg -------------------------------------------------------------------------------- /docs/restapi/feed.rst: -------------------------------------------------------------------------------- 1 | Feed 2 | ==== 3 | 4 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 5 | :endpoints: api.feed.feed 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | cache: pip 5 | 6 | install: 7 | - python setup.py develop 8 | 9 | script: pytest 10 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python manage.py -c $CONFIG db upgrade; python manage.py -c $CONFIG db prepare_default_data; gunicorn 'graygram.app:create_app()' -b 0.0.0.0:$PORT --log-file=- 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | *.pyc 7 | .cache/ 8 | *.db 9 | 10 | dev.cfg 11 | 12 | *.sublime-project 13 | *.sublime-workspace 14 | -------------------------------------------------------------------------------- /docs/restapi/users.rst: -------------------------------------------------------------------------------- 1 | User 2 | ==== 3 | 4 | Get My Profile 5 | -------------- 6 | 7 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 8 | :endpoints: api.users.get_me 9 | -------------------------------------------------------------------------------- /docs/restapi.rst: -------------------------------------------------------------------------------- 1 | REST API Reference 2 | ================== 3 | 4 | .. toctree:: 5 | 6 | restapi/overview 7 | restapi/auth 8 | restapi/feed 9 | restapi/posts 10 | restapi/users 11 | -------------------------------------------------------------------------------- /graygram/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | classes = frozenset([ 4 | 'credential.Credential', 5 | 'photo.Photo', 6 | 'post.Post', 7 | 'post_like.PostLike', 8 | 'user.User', 9 | ]) 10 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | 4 | 5 | def create_fixture(module_name, fixture_name, f): 6 | pytest.fixture(f) 7 | f.__name__ = fixture_name 8 | setattr(sys.modules[module_name], fixture_name, f) 9 | return f 10 | -------------------------------------------------------------------------------- /graygram/orm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | from sqlalchemy.dialects.postgresql import ENUM 5 | from sqlalchemy.dialects.postgresql import JSON 6 | 7 | 8 | db = SQLAlchemy() 9 | db.ENUM = ENUM 10 | db.JSON = JSON 11 | -------------------------------------------------------------------------------- /config/doc.cfg: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | APP_NAME = 'graygram' 4 | SERVER_NAME = 'www.graygram.com' 5 | SECRET_KEY = '' 6 | 7 | SQLALCHEMY_DATABASE_URI = '' 8 | SQLALCHEMY_TRACK_MODIFICATIONS = True 9 | REDIS_URL = '' 10 | 11 | S3_USERCONTENT_BUCKET = '' 12 | USERCONTENT_BASE_URL = '' 13 | -------------------------------------------------------------------------------- /graygram/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | blueprints = frozenset([ 4 | 'api.feed', 5 | 'api.join', 6 | 'api.index', 7 | 'api.login', 8 | 'api.logout', 9 | 'api.me', 10 | 'api.photos', 11 | 'api.posts', 12 | 'letsencrypt', 13 | 'photos', 14 | ]) 15 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Graygram documentation master file, created by 2 | sphinx-quickstart on Wed Feb 22 01:40:07 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Graygram Documentation 7 | ====================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | restapi 13 | -------------------------------------------------------------------------------- /graygram/paging.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import urllib 4 | 5 | from flask import request 6 | 7 | 8 | def next_url(limit=None, offset=None): 9 | limit = limit or request.values.get('limit', 30, type=int) 10 | offset = offset or request.values.get('offset', 0, type=int) 11 | values = request.values.to_dict() 12 | values['offset'] = limit + offset 13 | return request.base_url + '?' + urllib.urlencode(values) 14 | -------------------------------------------------------------------------------- /config/test.cfg: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | TESTING = True 4 | 5 | APP_NAME = 'graygram' 6 | SERVER_NAME = 'graygram.local:5000' 7 | SECRET_KEY = 'BA0852C5-82CA-46B5-B8D4-B4D0196F7250' 8 | 9 | SQLALCHEMY_DATABASE_URI = 'sqlite:///test.db' 10 | SQLALCHEMY_TRACK_MODIFICATIONS = True 11 | 12 | CACHE_TYPE = 'simple' 13 | 14 | S3_USERCONTENT_BUCKET = 'graygram-usercontent-test' 15 | USERCONTENT_BASE_URL = 'https://usercontent-test.graygram.com' 16 | -------------------------------------------------------------------------------- /graygram/views/letsencrypt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from flask import Blueprint 6 | 7 | 8 | view = Blueprint('letsencrypt', __name__) 9 | 10 | 11 | @view.route('/.well-known/acme-challenge/', subdomain='') 12 | @view.route('/.well-known/acme-challenge/', subdomain='www') 13 | @view.route('/.well-known/acme-challenge/', subdomain='api') 14 | def acme_challenge(key): 15 | return key + '.' + os.environ['ACME_CHALLENGE'] 16 | -------------------------------------------------------------------------------- /config/prod.cfg: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | APP_NAME = 'graygram' 6 | SERVER_NAME = 'graygram.com' 7 | SECRET_KEY = 'BA0852C5-82CA-46B5-B8D4-B4D0196F7250' 8 | 9 | SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URL'] 10 | SQLALCHEMY_TRACK_MODIFICATIONS = True 11 | 12 | CACHE_TYPE = 'redis' 13 | CACHE_REDIS_URL = os.environ['REDIS_URL'] 14 | 15 | S3_USERCONTENT_BUCKET = 'graygram-usercontent' 16 | USERCONTENT_BASE_URL = 'https://usercontent.graygram.com' 17 | -------------------------------------------------------------------------------- /docs/restapi/auth.rst: -------------------------------------------------------------------------------- 1 | Authentication 2 | ============== 3 | 4 | Login 5 | ----- 6 | 7 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 8 | :endpoints: api.login.username 9 | 10 | 11 | Join 12 | ---- 13 | 14 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 15 | :endpoints: api.join.username 16 | 17 | 18 | Logout 19 | ------ 20 | 21 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 22 | :endpoints: api.logout.logout 23 | -------------------------------------------------------------------------------- /graygram/views/api/index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | 5 | from graygram.renderers import render_json 6 | 7 | 8 | view = Blueprint('api.index', __name__) 9 | 10 | 11 | @view.route('/') 12 | def index(): 13 | data = { 14 | 'documentation_url': 'http://graygram.readthedocs.io/en/latest/', 15 | 'ios_repo_url': 'https://github.com/devxoul/graygram-ios', 16 | 'web_repo_url': 'https://github.com/devxoul/graygram-web', 17 | } 18 | return render_json(data) 19 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /tests/fixtures/user_fixtures.py: -------------------------------------------------------------------------------- 1 | from graygram import m 2 | from graygram.orm import db 3 | 4 | from . import create_fixture 5 | 6 | 7 | usernames = [ 8 | 'ironman', 9 | 'captain_america', 10 | ] 11 | 12 | 13 | def fixture_for(username): 14 | def fixture(request): 15 | password = 'password_' + username 16 | user = m.User.create(username=username, password=password) 17 | db.session.commit() 18 | return user 19 | return fixture 20 | 21 | 22 | for username in usernames: 23 | fixture_name = 'user_{}'.format(username) 24 | create_fixture(__name__, fixture_name, fixture_for(username)) 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Graygram 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /graygram/views/api/logout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask_login import logout_user 5 | 6 | from graygram.renderers import render_json 7 | 8 | 9 | view = Blueprint('api.logout', __name__) 10 | 11 | 12 | @view.route('/logout') 13 | def logout(): 14 | """Logout from the current session. 15 | 16 | **Example request**: 17 | 18 | .. sourcecode:: http 19 | 20 | GET /logout HTTP/1.1 21 | Accept: application/json 22 | 23 | **Example response**: 24 | 25 | .. sourcecode:: http 26 | 27 | HTTP/1.1 200 OK 28 | Content-Type: application/json 29 | 30 | {} 31 | """ 32 | 33 | logout_user() 34 | return render_json({}) 35 | -------------------------------------------------------------------------------- /graygram/models/post_like.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sqlalchemy.sql import functions as sqlfuncs 4 | 5 | from graygram.orm import db 6 | 7 | 8 | class PostLike(db.Model): 9 | user = db.relationship('User') 10 | user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) 11 | 12 | post = db.relationship('Post') 13 | post_id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True) 14 | 15 | liked_at = db.Column(db.DateTime(timezone=True), nullable=False, 16 | server_default=sqlfuncs.now(), index=True) 17 | 18 | def serialize(self): 19 | return { 20 | 'user': self.user, 21 | 'liked_at': self.liked_at, 22 | } 23 | -------------------------------------------------------------------------------- /graygram/views/api/photos.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | from flask_login import login_required 6 | 7 | from graygram import m 8 | from graygram import photo_uploader 9 | from graygram.exceptions import BadRequest 10 | from graygram.renderers import render_json 11 | 12 | 13 | view = Blueprint('api.photos', __name__, url_prefix='/photos') 14 | 15 | 16 | @view.route('', methods=['POST']) 17 | @login_required 18 | def upload_photo(): 19 | if 'file' not in request.files: 20 | raise BadRequest("Missing parameter: 'file'") 21 | try: 22 | photo = m.Photo.upload(file=request.files['file']) 23 | except photo_uploader.InvalidImage: 24 | raise BadRequest("Invalid image") 25 | return render_json(photo), 201 26 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import current_app 4 | from flask_migrate import MigrateCommand 5 | from flask_script import Manager 6 | 7 | from graygram import m 8 | from graygram.app import create_app 9 | from graygram.orm import db 10 | 11 | 12 | manager = Manager(create_app) 13 | manager.add_option('-c', '--config', dest='config', required=True) 14 | manager.add_command('db', MigrateCommand) 15 | 16 | 17 | @manager.shell 18 | def make_shell_context(): 19 | return { 20 | 'app': current_app, 21 | 'db': db, 22 | 'm': m, 23 | } 24 | 25 | 26 | @manager.command 27 | def prepare_default_data(): 28 | """Prepares default data""" 29 | from graygram.app import prepare_default_data 30 | prepare_default_data(current_app) 31 | 32 | 33 | if __name__ == '__main__': 34 | manager.run() 35 | -------------------------------------------------------------------------------- /docs/restapi/overview.rst: -------------------------------------------------------------------------------- 1 | REST API Overview 2 | ================= 3 | 4 | Endpoints 5 | --------- 6 | 7 | https://api.graygram.com 8 | 9 | 10 | Image Request 11 | ------------- 12 | 13 | **Endpoint**: https://www.graygram.com 14 | 15 | .. http:get:: /photos/(photo_id)/(int:width)x(int:height) 16 | 17 | Get the binary of resized image. The value of (`width`) and (`height`) must be equal. 18 | 19 | 20 | Error Response 21 | -------------- 22 | 23 | The error response contains a json object named ``error``. This object contains optional ``message`` and optional ``field``. 24 | 25 | **Example response**: 26 | 27 | .. sourcecode:: http 28 | 29 | HTTP/1.1 400 Bad Request 30 | Content-Type: application/json 31 | 32 | { 33 | "error": { 34 | "message": "Missing parameter", 35 | "field": "username" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /graygram/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sqlalchemy.util import dependencies 4 | 5 | 6 | class ModelImporter(object): 7 | 8 | _unresolved = {} 9 | 10 | def __init__(self): 11 | from graygram.models import classes 12 | 13 | for class_ in classes: 14 | components = class_.split('.') 15 | name = components[-1] 16 | path = 'graygram.models.%s' % components[-2] 17 | importer = dependencies._importlater(path, name) 18 | self._unresolved[name] = importer 19 | 20 | def resolve_all(self): 21 | for name in self._unresolved.keys(): 22 | importer = self._unresolved[name] 23 | importer._resolve() 24 | setattr(self, name, importer.module) 25 | del self._unresolved[name] 26 | 27 | m = ModelImporter() 28 | m.resolve_all() 29 | -------------------------------------------------------------------------------- /graygram/models/credential.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sqlalchemy.sql import functions as sqlfuncs 4 | 5 | from graygram.orm import db 6 | 7 | 8 | class Credential(db.Model): 9 | 10 | TYPES = frozenset(['username', 'admin']) 11 | 12 | id = db.Column(db.Integer, primary_key=True) 13 | user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, 14 | index=True) 15 | type = db.Column(db.ENUM(*TYPES, name='credential_types'), nullable=False) 16 | 17 | #: `username`: username 18 | #: `admin`: email 19 | key = db.Column(db.String(255), nullable=False, unique=True) 20 | 21 | #: `username`: bcrypt password 22 | #: `admin`: None 23 | value = db.Column(db.String(255)) 24 | 25 | created_at = db.Column(db.DateTime(timezone=True), nullable=False, 26 | server_default=sqlfuncs.now()) 27 | -------------------------------------------------------------------------------- /tests/fixtures/post_fixtures.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import uuid 5 | 6 | from graygram import m 7 | from graygram.orm import db 8 | 9 | 10 | def _create_fixture(topic, user): 11 | photo = m.Photo() 12 | photo.id = str(uuid.uuid4()) 13 | photo.user = user 14 | photo.original_width = 1280 15 | photo.original_height = 1280 16 | db.session.add(photo) 17 | post = m.Post() 18 | post.user = user 19 | post.photo = photo 20 | post.message = 'It\'s {}!'.format(topic) 21 | db.session.add(post) 22 | db.session.commit() 23 | return post 24 | 25 | 26 | @pytest.fixture 27 | def post_tower_eiffel(request, user_ironman): 28 | return _create_fixture('tower_eiffel', user_ironman) 29 | 30 | 31 | @pytest.fixture 32 | def post_espresso(request, user_captain_america): 33 | return _create_fixture('espresso', user_captain_america) 34 | -------------------------------------------------------------------------------- /docs/restapi/posts.rst: -------------------------------------------------------------------------------- 1 | Post 2 | ==== 3 | 4 | Get Post 5 | -------- 6 | 7 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 8 | :endpoints: api.posts.get_post 9 | 10 | 11 | Create Post 12 | ----------- 13 | 14 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 15 | :endpoints: api.posts.create_post 16 | 17 | 18 | Edit Post 19 | --------- 20 | 21 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 22 | :endpoints: api.posts.update_post 23 | 24 | 25 | Delete Post 26 | ----------- 27 | 28 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 29 | :endpoints: api.posts.delete_post 30 | 31 | 32 | Like Post 33 | --------- 34 | 35 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 36 | :endpoints: api.posts.like_post 37 | 38 | 39 | Unlike Post 40 | ----------- 41 | 42 | .. autoflask:: graygram.app:create_app(config='../config/doc.cfg') 43 | :endpoints: api.posts.unlike_post 44 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /graygram/cache.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from flask_cache import Cache 4 | 5 | from graygram.orm import db 6 | 7 | 8 | MODEL_BASE_CLASS = db.Model 9 | cache = Cache() 10 | 11 | 12 | def init_app(app): 13 | cache.init_app(app) 14 | 15 | 16 | def cached(*args, **kwargs): 17 | return cache.memoize(*args, **kwargs) 18 | 19 | 20 | def memoize(*args, **kwargs): 21 | return cache.memoize(*args, **kwargs) 22 | 23 | 24 | def delete_memoized(*args, **kwargs): 25 | if len(args) >= 2 and isinstance(args[1], MODEL_BASE_CLASS): 26 | return delete_memoized_hybrid(*args) 27 | else: 28 | return cache.delete_memoized(*args, **kwargs) 29 | 30 | 31 | def delete_memoized_hybrid(name, obj, *args): 32 | f = getattr(type(obj), name) 33 | if not callable(f): 34 | f = f.fget 35 | if inspect.ismethod(f): # hybrid_method 36 | f = getattr(obj, name) 37 | return cache.delete_memoized(f, obj, *args) 38 | 39 | 40 | def clear(*args, **kwargs): 41 | return cache.clear() 42 | -------------------------------------------------------------------------------- /graygram/models/photo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_login import current_user 4 | 5 | from graygram import m 6 | from graygram import photo_uploader 7 | from graygram.orm import db 8 | 9 | 10 | class Photo(db.Model): 11 | id = db.Column(db.String(36), primary_key=True) # uuid 12 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 13 | user = db.relationship('User', foreign_keys=[user_id]) 14 | original_width = db.Column(db.Integer) 15 | original_height = db.Column(db.Integer) 16 | 17 | def serialize(self): 18 | return { 19 | 'id': self.id, 20 | } 21 | 22 | @classmethod 23 | def upload(cls, file): 24 | result = photo_uploader.upload(file) 25 | photo = m.Photo() 26 | photo.id = result.id 27 | photo.user = current_user 28 | photo.original_width = result.width 29 | photo.original_height = result.height 30 | db.session.add(photo) 31 | db.session.commit() 32 | return photo 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | install_requires = [ 6 | 'boto3==1.4.4', 7 | 'Flask-Bcrypt==0.7.1', 8 | 'Flask-Cache==0.13.1', 9 | 'Flask-Login==0.4.0', 10 | 'Flask-Migrate==2.0.2', 11 | 'Flask-Script==2.0.5', 12 | 'Flask-SQLAlchemy==2.1', 13 | 'Flask-SSLify==0.1.5', 14 | 'Flask==0.12', 15 | 'gunicorn==19.6.0', 16 | 'psycopg2==2.6.2', 17 | 'pytz==2016.10', 18 | 'raven[flask]==5.32.0', 19 | 'redis==2.10.5', 20 | 'wand==0.4.4', 21 | ] 22 | 23 | tests_requires = [ 24 | 'pytest==3.0.6', 25 | ] 26 | 27 | docs_requires = [ 28 | 'Sphinx==1.5.2', 29 | 'sphinxcontrib-httpdomain==1.5.0', 30 | 'sphinx-rtd-theme==0.1.9', 31 | ] 32 | 33 | setup(name='Graygram', 34 | version='0.1.0', 35 | description='Graygram', 36 | author='Suyeol Jeon', 37 | author_email='devxoul@gmail.com', 38 | url='https://github.com/devxoul/graygram-web', 39 | packages=['graygram'], 40 | install_requires=install_requires + tests_requires + docs_requires, 41 | tests_requires=tests_requires) 42 | -------------------------------------------------------------------------------- /tests/views/test_join.py: -------------------------------------------------------------------------------- 1 | def test_join_failure_missing_username(api): 2 | r = api.post('/join/username', data={}) 3 | assert r.status_code == 400 4 | assert r.json['error']['field'] == 'username' 5 | assert 'missing' in r.json['error']['message'].lower() 6 | 7 | 8 | def test_join_failure_missing_password(api): 9 | r = api.post('/join/username', data={ 10 | 'username': 'abc', 11 | }) 12 | assert r.status_code == 400 13 | assert r.json['error']['field'] == 'password' 14 | assert 'missing' in r.json['error']['message'].lower() 15 | 16 | 17 | def test_join_failure_username_alread_exists(api, user_ironman): 18 | r = api.post('/join/username', data={ 19 | 'username': 'ironman', 20 | 'password': 'secret', 21 | }) 22 | assert r.status_code == 409 23 | assert r.json['error']['field'] == 'username' 24 | assert 'already exists' in r.json['error']['message'] 25 | 26 | 27 | def test_join_success(api): 28 | r = api.post('/join/username', data={ 29 | 'username': 'ironman', 30 | 'password': 'password_ironman', 31 | }) 32 | assert r.status_code == 201 33 | -------------------------------------------------------------------------------- /tests/views/test_login.py: -------------------------------------------------------------------------------- 1 | def test_login_failure_missing_username(api): 2 | r = api.post('/login/username', data={}) 3 | assert r.status_code == 400 4 | assert r.json['error']['field'] == 'username' 5 | assert 'missing' in r.json['error']['message'].lower() 6 | 7 | 8 | def test_login_failure_missing_password(api): 9 | r = api.post('/login/username', data={ 10 | 'username': 'abc', 11 | }) 12 | assert r.status_code == 400 13 | assert r.json['error']['field'] == 'password' 14 | assert 'missing' in r.json['error']['message'].lower() 15 | 16 | 17 | def test_login_failure_unregistered_user(api): 18 | r = api.post('/login/username', data={ 19 | 'username': 'unknown', 20 | 'password': 'secret', 21 | }) 22 | assert r.status_code == 400 23 | assert r.json['error']['field'] == 'username' 24 | assert 'not registered' in r.json['error']['message'].lower() 25 | 26 | 27 | def test_login_success(api, user_ironman): 28 | r = api.post('/login/username', data={ 29 | 'username': 'ironman', 30 | 'password': 'password_ironman', 31 | }) 32 | assert r.status_code == 200 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Suyeol Jeon (http://xoul.kr) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /migrations/versions/8dd6149e6a9c_add_post_like.py: -------------------------------------------------------------------------------- 1 | """Add post_like 2 | 3 | Revision ID: 8dd6149e6a9c 4 | Revises: e851b34d79b8 5 | Create Date: 2017-02-17 04:14:11.737063 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8dd6149e6a9c' 14 | down_revision = 'e851b34d79b8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('post_like', 22 | sa.Column('user_id', sa.Integer(), nullable=False), 23 | sa.Column('post_id', sa.Integer(), nullable=False), 24 | sa.Column('liked_at', sa.DateTime(timezone=True), server_default=sa.text(u'now()'), nullable=False), 25 | sa.ForeignKeyConstraint(['post_id'], ['post.id'], ), 26 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 27 | sa.PrimaryKeyConstraint('user_id', 'post_id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('post_like') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def import_fixtures(): 6 | module_names = _get_fixture_module_names() 7 | for module_name in module_names: 8 | _import_fixtures_from(module_name) 9 | 10 | 11 | def _get_fixture_module_names(): 12 | module_names = [] 13 | for filename in os.listdir('./tests/fixtures'): 14 | if not filename.startswith('_') and filename.endswith('.py'): 15 | module_name = filename.split('.')[0] 16 | module_names.append(module_name) 17 | return module_names 18 | 19 | 20 | def _import_fixtures_from(module_name): 21 | path = 'tests.fixtures.{}'.format(module_name) 22 | __import__(path, fromlist=[module_name]) 23 | fixtures = _get_fixtures_from(path) 24 | for name, fixture in fixtures: 25 | setattr(sys.modules['tests.conftest'], name, fixture) 26 | 27 | 28 | def _get_fixtures_from(name): 29 | fixtures = [] 30 | for key in dir(sys.modules[name]): 31 | if key.startswith('_'): 32 | continue 33 | value = getattr(sys.modules[name], key) 34 | try: 35 | getattr(value, '_pytestfixturefunction') 36 | fixtures.append((key, value)) 37 | except AttributeError: 38 | continue 39 | return fixtures 40 | -------------------------------------------------------------------------------- /graygram/s3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import boto3 4 | 5 | from botocore.client import Config 6 | 7 | 8 | class LazyBucket(object): 9 | _bucket = None 10 | 11 | def resolve(self, bucket): 12 | self._bucket = bucket 13 | 14 | def __getattr__(self, name): 15 | return getattr(self._bucket, name) 16 | 17 | def __setattr__(self, name, value): 18 | if name == '_bucket': 19 | super(LazyBucket, self).__setattr__(name, value) 20 | else: 21 | setattr(self._bucket, name, value) 22 | 23 | @property 24 | def baseurl(self): 25 | return 'https://s3.ap-northeast-2.amazonaws.com/' + self._bucket.name 26 | 27 | def url_for(self, key): 28 | return self.baseurl + '/' + key 29 | 30 | def head_object(self, key): 31 | return client.head_object(Bucket=self.name, Key=key) 32 | 33 | def object_exists(self, key): 34 | try: 35 | self.head_object(key) 36 | return True 37 | except: 38 | return False 39 | 40 | 41 | _config = Config(signature_version='s3v4', region_name='ap-northeast-2') 42 | client = boto3.client('s3', config=_config) 43 | resource = boto3.resource('s3', config=_config) 44 | usercontent_bucket = LazyBucket() 45 | 46 | 47 | def init_app(app): 48 | bucket = resource.Bucket(app.config['S3_USERCONTENT_BUCKET']) 49 | usercontent_bucket.resolve(bucket) 50 | -------------------------------------------------------------------------------- /migrations/versions/3f0436a7bc65_create_first_index.py: -------------------------------------------------------------------------------- 1 | """create_first_index 2 | 3 | Revision ID: 3f0436a7bc65 4 | Revises: 8dd6149e6a9c 5 | Create Date: 2017-02-27 03:10:24.775429 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '3f0436a7bc65' 14 | down_revision = '8dd6149e6a9c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_index(op.f('ix_credential_user_id'), 'credential', ['user_id'], unique=False) 22 | op.create_index(op.f('ix_post_created_at'), 'post', ['created_at'], unique=False) 23 | op.create_index(op.f('ix_post_user_id'), 'post', ['user_id'], unique=False) 24 | op.create_index(op.f('ix_post_like_liked_at'), 'post_like', ['liked_at'], unique=False) 25 | op.create_index(op.f('ix_user_created_at'), 'user', ['created_at'], unique=False) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_index(op.f('ix_user_created_at'), table_name='user') 32 | op.drop_index(op.f('ix_post_like_liked_at'), table_name='post_like') 33 | op.drop_index(op.f('ix_post_user_id'), table_name='post') 34 | op.drop_index(op.f('ix_post_created_at'), table_name='post') 35 | op.drop_index(op.f('ix_credential_user_id'), table_name='credential') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graygram 2 | 3 | [![Build Status](https://travis-ci.org/devxoul/graygram-web.svg?branch=master)](https://travis-ci.org/devxoul/graygram-web) 4 | [![Documentation Status](https://readthedocs.org/projects/graygram/badge/?version=latest)](http://graygram.readthedocs.io/en/latest/?badge=latest) 5 | 6 | The backend server application for [Graygram](https://www.graygram.com). Written in Python 2.7 and Flask. 7 | 8 | * [Documentation](http://graygram.readthedocs.io/en/latest/) 9 | * [Graygram for iOS](https://github.com/devxoul/graygram-ios) 10 | 11 | ## Development 12 | 13 | ```console 14 | $ python setup.py develop 15 | $ python manage.py -c YOUR_CONFIGURATION_FILE db upgrade 16 | $ python manage.py -c YOUR_CONFIGURATION_FILE runserver 17 | ``` 18 | 19 | Graygram uses subdomain for its API host. I'd recommend you to add following to your /etc/hosts file. 20 | 21 | ``` 22 | 127.0.0.1 graygram.local 23 | 127.0.0.1 www.graygram.local 24 | 127.0.0.1 api.graygram.local 25 | 127.0.0.1 usercontent.graygram.local 26 | ``` 27 | 28 | Then you'll be able to send a request to your local server: `http://api.graygram.local:5000` 29 | 30 | ## Testing 31 | 32 | ```console 33 | $ pytest 34 | ``` 35 | 36 | ## Documentation 37 | 38 | ```console 39 | $ cd docs 40 | $ make clean html 41 | $ open build/html/index.html 42 | ``` 43 | 44 | ## Deployment 45 | 46 | Graygram is being served on Heroku. 47 | 48 | ## License 49 | 50 | Graygram is under MIT license. See the [LICENSE](LICENSE) file for more info. 51 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy.ext.hybrid import hybrid_method 4 | from sqlalchemy.ext.hybrid import hybrid_property 5 | 6 | from graygram import cache 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def change_model_base_class(request): 11 | cache.MODEL_BASE_CLASS = Model 12 | 13 | def teardown(): 14 | from graygram.orm import db 15 | cache.MODEL_BASE_CLASS = db.Model 16 | request.addfinalizer(teardown) 17 | 18 | 19 | class Model(object): 20 | pass 21 | 22 | 23 | class Foo(Model): 24 | value = 10 25 | 26 | @hybrid_property 27 | @cache.memoize(timeout=300) 28 | def cached_value(self): 29 | return self.value 30 | 31 | @hybrid_method 32 | @cache.memoize(timeout=300) 33 | def cached_multiply(self, x): 34 | return self.value * x 35 | 36 | @cached_multiply.expression 37 | def cached_multiply(cls, x): 38 | return None 39 | 40 | 41 | def test_hybrid_property(app): 42 | foo = Foo() 43 | assert foo.cached_value == 10 44 | 45 | foo.value = 20 46 | assert foo.cached_value == 10 # use cached value 47 | 48 | cache.delete_memoized('cached_value', foo) 49 | assert foo.cached_value == 20 50 | 51 | 52 | def test_hybrid_method(app): 53 | foo = Foo() 54 | assert foo.cached_multiply(2) == 20 55 | 56 | foo.value = 20 57 | assert foo.cached_multiply(2) == 20 # use cached value 58 | 59 | cache.delete_memoized('cached_multiply', foo, 2) 60 | assert foo.cached_multiply(2) == 40 61 | -------------------------------------------------------------------------------- /graygram/models/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_login import UserMixin 4 | from sqlalchemy.sql import functions as sqlfuncs 5 | 6 | from graygram import m 7 | from graygram.crypto import bcrypt 8 | from graygram.orm import db 9 | 10 | 11 | class User(db.Model, UserMixin): 12 | id = db.Column(db.Integer, primary_key=True) 13 | username = db.Column(db.String(80), nullable=False) 14 | 15 | photo_id = db.Column(db.String(36), db.ForeignKey('photo.id')) 16 | photo = db.relationship('Photo', foreign_keys=[photo_id]) 17 | 18 | created_at = db.Column(db.DateTime(timezone=True), nullable=False, 19 | server_default=sqlfuncs.now(), index=True) 20 | 21 | credentials = db.relationship('Credential', backref='user', lazy='dynamic') 22 | 23 | def __repr__(self): 24 | return '' % self.id 25 | 26 | @classmethod 27 | def create(cls, username, password): 28 | user = m.User() 29 | user.username = username 30 | db.session.add(user) 31 | 32 | cred = m.Credential() 33 | cred.user = user 34 | cred.type = 'username' 35 | cred.key = username 36 | cred.value = bcrypt.generate_password_hash(password) 37 | db.session.add(cred) 38 | 39 | return user 40 | 41 | def serialize(self): 42 | return { 43 | 'id': self.id, 44 | 'username': self.username, 45 | 'photo': self.photo or m.Photo.query.get('_default/user'), 46 | 'created_at': self.created_at, 47 | } 48 | -------------------------------------------------------------------------------- /tests/clients.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask.testing import FlaskClient 4 | 5 | 6 | class SubdomainClient(FlaskClient): 7 | 8 | subdomain = None 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.subdomain = kwargs.get('subdomain') 12 | super(SubdomainClient, self).__init__(*args, **kwargs) 13 | 14 | def _get_http_host(self): 15 | if self.subdomain is None: 16 | return None 17 | try: 18 | server_name = self.application.config['SERVER_NAME'] 19 | except KeyError: 20 | return None 21 | return self.subdomain + '.' + server_name 22 | 23 | def open(self, *args, **kwargs): 24 | kwargs.setdefault('environ_overrides', {}) 25 | kwargs['environ_overrides']['HTTP_HOST'] = self._get_http_host() 26 | return super(SubdomainClient, self).open(*args, **kwargs) 27 | 28 | 29 | class APIClient(SubdomainClient): 30 | 31 | headers = {} 32 | follow_redirects = True 33 | 34 | def __init__(self, *args, **kwargs): 35 | self.headers = kwargs.get('headers', self.headers) 36 | self.follow_redirects = kwargs.get('follow_redirects', 37 | self.follow_redirects) 38 | super(APIClient, self).__init__(*args, **kwargs) 39 | 40 | def open(self, *args, **kwargs): 41 | kwargs['headers'] = self.headers 42 | kwargs['follow_redirects'] = self.follow_redirects 43 | response = super(APIClient, self).open(*args, **kwargs) 44 | if 'application/json' in self.headers.get('Accept'): 45 | response.json = json.loads(response.data) 46 | return response 47 | -------------------------------------------------------------------------------- /graygram/renderers/json_renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import pytz 5 | 6 | from datetime import datetime 7 | from flask import Response 8 | from flask_sqlalchemy import BaseQuery 9 | 10 | from graygram.orm import db 11 | 12 | 13 | def render_json(data=None): 14 | if not data: 15 | if isinstance(data, list): 16 | return Response('{"data": []}', mimetype='application/json') 17 | return Response('{}', mimetype='application/json') 18 | 19 | json_data = {} 20 | if isinstance(data, db.Model): 21 | json_data = data.serialize() 22 | 23 | elif isinstance(data, list) or isinstance(data, BaseQuery): 24 | json_data['data'] = [] 25 | for v in data: 26 | if isinstance(v, dict): 27 | json_data['data'].append(v) 28 | elif isinstance(v, db.Model): 29 | json_data['data'].append(v.serialize()) 30 | 31 | else: 32 | json_data = data 33 | 34 | json_data = traverse(json_data) 35 | return Response(json.dumps(json_data), mimetype='application/json') 36 | 37 | 38 | def traverse(data): 39 | if isinstance(data, datetime): 40 | fmt = '%Y-%m-%dT%H:%M:%S%z' 41 | try: 42 | return data.astimezone(pytz.utc).strftime(fmt) 43 | except ValueError: 44 | return data.strftime(fmt) 45 | 46 | if isinstance(data, db.Model) and data.serialize: 47 | return traverse(data.serialize()) 48 | 49 | if isinstance(data, dict): 50 | return {k: traverse(v) for k, v in data.iteritems()} 51 | 52 | if isinstance(data, list): 53 | return [traverse(d) for d in data] 54 | 55 | return data 56 | -------------------------------------------------------------------------------- /tests/views/test_me.py: -------------------------------------------------------------------------------- 1 | from graygram.s3 import usercontent_bucket 2 | 3 | 4 | def test_get_me_failure__not_logged_in(api): 5 | r = api.get('/me') 6 | assert r.status_code == 401 7 | 8 | 9 | def test_get_me_success(api, login, user_ironman): 10 | login(user_ironman) 11 | r = api.get('/me') 12 | assert r.status_code == 200 13 | assert r.json['username'] == 'ironman' 14 | 15 | 16 | def test_update_profile_photo__failure_not_logged_in(api, login): 17 | r = api.put('/me/photo') 18 | assert r.status_code == 401 19 | 20 | 21 | def test_update_profile_photo__failure_no_file(api, login, user_ironman): 22 | login(user_ironman) 23 | r = api.put('/me/photo') 24 | assert r.status_code == 400 25 | assert r.json['error']['field'] == 'photo' 26 | assert 'missing' in r.json['error']['message'].lower() 27 | 28 | 29 | def test_update_profile_photo__success(api, login, user_ironman): 30 | login(user_ironman) 31 | r = api.put('/me/photo', data={ 32 | 'photo': open('./tests/images/ironman.png'), 33 | }) 34 | assert r.status_code == 200 35 | key = r.json['photo']['id'] + '/original' 36 | assert usercontent_bucket.object_exists(key) 37 | 38 | 39 | def test_delete_profile_photo__failure_not_logged_in(api, login): 40 | r = api.delete('/me/photo') 41 | assert r.status_code == 401 42 | 43 | 44 | def test_delete_profile_photo__success(api, login, user_ironman): 45 | login(user_ironman) 46 | r = api.put('/me/photo', data={ 47 | 'photo': open('./tests/images/ironman.png'), 48 | }) 49 | r = api.delete('/me/photo') 50 | assert r.status_code == 200 51 | assert r.json['photo']['id'] == '_default/user' 52 | -------------------------------------------------------------------------------- /graygram/views/api/me.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | from flask_login import current_user 6 | from flask_login import login_required 7 | 8 | from graygram import m 9 | from graygram.exceptions import BadRequest 10 | from graygram.orm import db 11 | from graygram.photo_uploader import InvalidImage 12 | from graygram.renderers import render_json 13 | 14 | 15 | view = Blueprint('api.users', __name__) 16 | 17 | 18 | @view.route('/me') 19 | @login_required 20 | def get_me(): 21 | """Get my profile 22 | 23 | **Example JSON response**: 24 | 25 | .. sourcecode:: json 26 | 27 | { 28 | "username": "devxoul", 29 | "photo": null, 30 | "created_at": "2017-02-21T16:59:35+0000", 31 | "id": 1 32 | } 33 | 34 | :statuscode 401: Not authorized 35 | """ 36 | return render_json(current_user.serialize()) 37 | 38 | 39 | @view.route('/me/photo', methods=['PUT']) 40 | @login_required 41 | def update_me_photo(): 42 | if 'photo' not in request.files: 43 | raise BadRequest(message="Missing parameter", field='photo') 44 | try: 45 | photo = m.Photo.upload(file=request.files['photo']) 46 | except InvalidImage: 47 | raise BadRequest(message="Invalid image", field='photo') 48 | current_user.photo = photo 49 | db.session.add(current_user) 50 | db.session.commit() 51 | return render_json(current_user.serialize()) 52 | 53 | 54 | @view.route('/me/photo', methods=['DELETE']) 55 | @login_required 56 | def delete_me_photo(): 57 | current_user.photo = None 58 | db.session.add(current_user) 59 | db.session.commit() 60 | return render_json(current_user.serialize()) 61 | -------------------------------------------------------------------------------- /graygram/views/photos.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from botocore.exceptions import ClientError 4 | from flask import Blueprint 5 | from flask import current_app 6 | from flask import redirect 7 | from StringIO import StringIO 8 | 9 | from graygram import photo_uploader 10 | from graygram.exceptions import NotFound 11 | from graygram.s3 import usercontent_bucket 12 | 13 | 14 | def usercontent_url(*path_components): 15 | base_url = current_app.config['USERCONTENT_BASE_URL'] 16 | return '/'.join([base_url] + list(path_components)) 17 | 18 | 19 | view = Blueprint('photos', __name__, url_prefix='/photos') 20 | 21 | 22 | @view.route('/') 23 | @view.route('//original') 24 | def get_original(photo_id): 25 | return redirect(usercontent_url(photo_id, 'original')) 26 | 27 | 28 | @view.route('//x') 29 | def get_resized(photo_id, width, height): 30 | if width <= 0 or height <= 0 or width != height: 31 | raise NotFound() 32 | key = '{photo_id}/{width}x{height}'.format(photo_id=photo_id, 33 | width=width, 34 | height=height) 35 | try: 36 | usercontent_bucket.Object(key).get() # check existing 37 | except ClientError as e: 38 | if e.response['Error']['Code'] != 'NoSuchKey': 39 | raise e 40 | original = StringIO() 41 | usercontent_bucket.download_fileobj(photo_id + '/original', original) 42 | original.seek(0) 43 | photo_uploader.upload(original, 44 | photo_id=photo_id, 45 | resize=(width, height)) 46 | return redirect(usercontent_url(key)) 47 | -------------------------------------------------------------------------------- /graygram/photo_uploader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import uuid 4 | 5 | from wand.exceptions import MissingDelegateError 6 | from wand.image import Image 7 | 8 | from graygram.s3 import usercontent_bucket 9 | 10 | 11 | class PhotoUploaderException(Exception): 12 | pass 13 | 14 | 15 | class InvalidImage(PhotoUploaderException): 16 | pass 17 | 18 | 19 | class UploadResult(object): 20 | def __init__(self, id, width, height): 21 | self.id = id 22 | self.width = width 23 | self.height = height 24 | 25 | 26 | def upload(file, photo_id=None, resize=None): 27 | photo_id = photo_id or str(uuid.uuid4()) 28 | try: 29 | with Image(file=file) as image: 30 | if resize and len(resize) == 2: 31 | key = '{photo_id}/{width}x{height}'.format(photo_id=photo_id, 32 | width=resize[0], 33 | height=resize[1]) 34 | length = min(image.size) 35 | image.crop(width=length, height=length, gravity='center') 36 | image.resize(resize[0], resize[1]) 37 | else: 38 | key = photo_id + '/original' 39 | usercontent_bucket.put_object( 40 | ACL='public-read', 41 | Key=key, 42 | Body=image.make_blob(), 43 | ContentType='image/{}'.format(image.format.lower()), 44 | Metadata={ 45 | 'width': str(image.size[0]), 46 | 'height': str(image.size[1]), 47 | } 48 | ) 49 | return UploadResult(photo_id, image.size[0], image.size[1]) 50 | 51 | except MissingDelegateError: 52 | raise InvalidImage() 53 | -------------------------------------------------------------------------------- /graygram/views/api/login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | from flask_login import login_user 6 | 7 | from graygram import m 8 | from graygram.crypto import bcrypt 9 | from graygram.exceptions import BadRequest 10 | from graygram.renderers import render_json 11 | 12 | 13 | view = Blueprint('api.login', __name__, url_prefix='/login') 14 | 15 | 16 | @view.route('/username', methods=['POST']) 17 | def username(): 18 | """Login using username and password. 19 | 20 | **Example request**: 21 | 22 | .. sourcecode:: http 23 | 24 | POST /login/username HTTP/1.1 25 | Accept: application/json 26 | 27 | **Example response**: 28 | 29 | .. sourcecode:: http 30 | 31 | HTTP/1.1 200 OK 32 | Content-Type: application/json 33 | 34 | { 35 | "username": "devxoul", 36 | "photo": null, 37 | "created_at": "2017-02-21T16:59:35+0000", 38 | "id": 1 39 | } 40 | 41 | :form username: Username of the user 42 | :form password: Password of the user 43 | :statuscode 200: Succeeded to login 44 | :statuscode 400: Missing or wrong parameters 45 | """ 46 | 47 | username = request.values.get('username') 48 | if not username: 49 | raise BadRequest(message="Missing parameter", field='username') 50 | 51 | password = request.values.get('password') 52 | if not password: 53 | raise BadRequest(message="Missing parameter", field='password') 54 | 55 | cred = m.Credential.query.filter_by(type='username', key=username).first() 56 | if not cred: 57 | raise BadRequest(message="User not registered", field='username') 58 | 59 | password_correct = bcrypt.check_password_hash(cred.value, password) 60 | if not password_correct: 61 | raise BadRequest(message="Wrong password", field='password') 62 | 63 | login_user(cred.user, remember=True) 64 | return render_json(cred.user) 65 | -------------------------------------------------------------------------------- /graygram/views/api/join.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | from flask_login import login_user 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | from graygram import m 9 | from graygram.exceptions import BadRequest 10 | from graygram.exceptions import Conflict 11 | from graygram.orm import db 12 | from graygram.renderers import render_json 13 | 14 | 15 | view = Blueprint('api.join', __name__, url_prefix='/join') 16 | 17 | 18 | @view.route('/username', methods=['POST']) 19 | def username(): 20 | """Create a new user with username and password. 21 | 22 | **Example request**: 23 | 24 | .. sourcecode:: http 25 | 26 | POST /join/username HTTP/1.1 27 | Accept: application/json 28 | 29 | **Example response**: 30 | 31 | .. sourcecode:: http 32 | 33 | HTTP/1.1 201 OK 34 | Content-Type: application/json 35 | 36 | { 37 | "username": "devxoul", 38 | "photo": null, 39 | "created_at": "2017-02-21T16:59:35+0000", 40 | "id": 1 41 | } 42 | 43 | :form username: Username of the user 44 | :form password: Password of the user 45 | :statuscode 201: Succeeded to create a new user 46 | :statuscode 400: Missing or wrong parameters 47 | :statuscode 409: User already exists 48 | """ 49 | 50 | username = request.values.get('username') 51 | if not username: 52 | raise BadRequest(message="Missing parameter", field='username') 53 | 54 | password = request.values.get('password') 55 | if not password: 56 | raise BadRequest(message="Missing parameter", field='password') 57 | 58 | cred = m.Credential.query.filter_by(type='username', key=username).first() 59 | if cred: 60 | raise Conflict(message="User '{}' already exists.".format(username), 61 | field='username') 62 | 63 | user = m.User.create(username=username, password=password) 64 | 65 | try: 66 | db.session.commit() 67 | except IntegrityError: 68 | db.session.rollback() 69 | raise Conflict(message="User '{}' already exists.".format(username), 70 | field='username') 71 | 72 | login_user(user, remember=True) 73 | return render_json(user), 201 74 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clients import APIClient 4 | 5 | from graygram import cache 6 | from graygram.app import create_app 7 | from graygram.app import prepare_default_data 8 | from graygram.orm import db 9 | 10 | from . import import_fixtures 11 | 12 | 13 | import_fixtures() 14 | 15 | 16 | @pytest.fixture 17 | def app(request): 18 | app = create_app(config='config/test.cfg') 19 | ctx = app.app_context() 20 | ctx.push() 21 | db.create_all() 22 | prepare_default_data(app) 23 | cache.clear() 24 | 25 | def teardown(): 26 | db.session.close() 27 | for table in db.metadata.tables.keys(): 28 | db.session.execute('DROP TABLE {}'.format(table)) 29 | db.drop_all() 30 | cache.clear() 31 | ctx.pop() 32 | 33 | request.addfinalizer(teardown) 34 | return app 35 | 36 | 37 | @pytest.fixture 38 | def api(request, app): 39 | client = APIClient(app, app.response_class) 40 | client.subdomain = 'api' 41 | client.headers['Accept'] = 'application/json' 42 | return client 43 | 44 | 45 | @pytest.fixture 46 | def login(request, api): 47 | def _login(user): 48 | api.post('/login/username', data={ 49 | 'username': user.username, 50 | 'password': 'password_' + user.username, 51 | }) 52 | return _login 53 | 54 | 55 | @pytest.fixture(scope='session', autouse=True) 56 | def s3(request): 57 | from graygram.s3 import client 58 | 59 | def get_usercontent_bucket_name(): 60 | execfile('config/test.cfg') 61 | return locals()['S3_USERCONTENT_BUCKET'] 62 | 63 | bucket_name = get_usercontent_bucket_name() 64 | try: 65 | client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={ 66 | 'LocationConstraint': 'ap-northeast-2', 67 | }) 68 | except: 69 | pass 70 | 71 | def teardown(): 72 | try: 73 | clear_bucket(bucket_name) 74 | client.delete_bucket(Bucket=bucket_name) 75 | except: 76 | pass 77 | request.addfinalizer(teardown) 78 | 79 | 80 | def clear_bucket(bucket_name): 81 | from graygram.s3 import client 82 | r = client.list_objects(Bucket=bucket_name) 83 | objects = [dict(Key=content['Key']) for content in r['Contents']] 84 | client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects}) 85 | -------------------------------------------------------------------------------- /graygram/views/api/feed.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | 6 | from graygram import m 7 | from graygram.paging import next_url 8 | from graygram.renderers import render_json 9 | 10 | 11 | view = Blueprint('api.feed', __name__, url_prefix='/feed') 12 | 13 | 14 | @view.route('') 15 | def feed(): 16 | """ 17 | **Example request**: 18 | 19 | .. sourcecode:: http 20 | 21 | GET /feed HTTP/1.1 22 | Accept: application/json 23 | 24 | **Example response**: 25 | 26 | .. sourcecode:: http 27 | 28 | HTTP/1.1 200 OK 29 | Content-Type: application/json 30 | 31 | { 32 | "data": [ 33 | { 34 | "message": "생 마르탱 운하에서 본 할머니와 할아버지", 35 | "id": 14, 36 | "is_liked": false, 37 | "like_count": 0, 38 | "user": { 39 | "created_at": "2017-02-07T09:23:13+0000", 40 | "photo": { 41 | "id": "d7394923-cf39-4c78-891a-8714eb615ea7" 42 | }, 43 | "username": "devxoul", 44 | "id": 1 45 | }, 46 | "created_at": "2017-01-27T19:52:32+0000", 47 | "photo": { 48 | "id": "69e892b4-7dbd-403c-92fc-07f199c2be35" 49 | } 50 | }, 51 | { 52 | "message": "서울의 한 골목", 53 | "id": 13, 54 | "is_liked": false, 55 | "like_count": 0, 56 | "user": { 57 | "created_at": "2017-02-07T09:23:13+0000", 58 | "photo": { 59 | "id": "d7394923-cf39-4c78-891a-8714eb615ea7" 60 | }, 61 | "username": "devxoul", 62 | "id": 1 63 | }, 64 | "created_at": "2017-01-27T19:52:02+0000", 65 | "photo": { 66 | "id": "1e6833e2-f041-49be-9e4a-5691d084910d" 67 | } 68 | } 69 | ], 70 | "paging": { 71 | "next": "https://www.graygram.com/feed?limit=2&offset=2" 72 | }, 73 | } 74 | 75 | :query limit: 76 | :query offset: 77 | :>json array data: Array of ``Post`` 78 | :>json string paging.next: (Optional) Next page URL 79 | """ 80 | 81 | limit = request.values.get('limit', 30, type=int) 82 | offset = request.values.get('offset', 0, type=int) 83 | posts = m.Post.query \ 84 | .order_by(m.Post.created_at.desc()) \ 85 | .offset(offset) \ 86 | .limit(limit) 87 | data = { 88 | 'data': [post.serialize() for post in posts], 89 | 'paging': None, 90 | } 91 | if limit + offset < m.Post.query.count(): 92 | data['paging'] = { 93 | 'next': next_url(limit=limit, offset=offset), 94 | } 95 | return render_json(data) 96 | -------------------------------------------------------------------------------- /graygram/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from flask import Flask 6 | from flask import redirect 7 | from flask import request 8 | from werkzeug.exceptions import default_exceptions 9 | 10 | from graygram import cache 11 | from graygram import exceptions 12 | from graygram import s3 13 | from graygram.crypto import bcrypt 14 | from graygram.orm import db 15 | from graygram.views import blueprints 16 | 17 | 18 | def create_app(config=None): 19 | app = Flask(__name__) 20 | if config: 21 | app.config.from_pyfile(os.path.abspath(config)) 22 | else: 23 | app.config.from_envvar('CONFIG') 24 | 25 | if not app.debug and not app.testing: 26 | from flask.ext.sslify import SSLify 27 | SSLify(app, permanent=True) 28 | from raven.contrib.flask import Sentry 29 | Sentry(app) 30 | 31 | install_errorhandler(app) 32 | register_blueprints(app) 33 | 34 | cache.init_app(app) 35 | db.init_app(app) 36 | bcrypt.init_app(app) 37 | s3.init_app(app) 38 | init_extensions(app) 39 | 40 | @app.route('/') 41 | def index(): 42 | return redirect('https://github.com/devxoul/graygram-ios') 43 | 44 | return app 45 | 46 | 47 | def register_blueprints(app): 48 | app.url_map.default_subdomain = 'www' 49 | for blueprint_name in blueprints: 50 | path = 'graygram.views.%s' % blueprint_name 51 | view = __import__(path, fromlist=[blueprint_name]) 52 | blueprint = getattr(view, 'view') 53 | if blueprint_name.startswith('api.'): 54 | blueprint.subdomain = 'api' 55 | app.register_blueprint(blueprint) 56 | 57 | 58 | def init_extensions(app): 59 | from flask_login import LoginManager 60 | from flask_migrate import Migrate 61 | 62 | login = LoginManager(app=app) 63 | 64 | @login.user_loader 65 | def load_user(user_id): 66 | from graygram.models.user import User 67 | return User.query.get(user_id) 68 | 69 | Migrate(app, db) 70 | 71 | 72 | def install_errorhandler(app): 73 | def errorhandler(error): 74 | if not isinstance(error, exceptions.HTTPException): 75 | error = getattr(exceptions, error.__class__.__name__)() 76 | return error.get_response(request.environ) 77 | 78 | for code in default_exceptions.iterkeys(): 79 | app.register_error_handler(code, errorhandler) 80 | 81 | 82 | def prepare_default_data(app): 83 | from graygram import m 84 | with app.app_context(): 85 | default_user_photo_id = '_default/user' 86 | default_user_photo = m.Photo.query.get(default_user_photo_id) 87 | if default_user_photo is None: 88 | default_user_photo = m.Photo(id=default_user_photo_id, 89 | user_id=1, 90 | original_width=128, 91 | original_height=128,) 92 | db.session.add(default_user_photo) 93 | db.session.commit() 94 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/versions/e851b34d79b8_init.py: -------------------------------------------------------------------------------- 1 | """Init 2 | 3 | Revision ID: e851b34d79b8 4 | Revises: 5 | Create Date: 2017-02-22 01:15:23.829566 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e851b34d79b8' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('credential', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('user_id', sa.Integer(), nullable=False), 24 | sa.Column('type', postgresql.ENUM('username', 'admin', name='credential_types'), nullable=False), 25 | sa.Column('key', sa.String(length=255), nullable=False), 26 | sa.Column('value', sa.String(length=255), nullable=True), 27 | sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text(u'now()'), nullable=False), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('key') 30 | ) 31 | op.create_table('photo', 32 | sa.Column('id', sa.String(length=36), nullable=False), 33 | sa.Column('user_id', sa.Integer(), nullable=True), 34 | sa.Column('original_width', sa.Integer(), nullable=True), 35 | sa.Column('original_height', sa.Integer(), nullable=True), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | op.create_table('post', 39 | sa.Column('id', sa.Integer(), nullable=False), 40 | sa.Column('photo_id', sa.String(length=36), nullable=True), 41 | sa.Column('message', sa.Text(), nullable=True), 42 | sa.Column('user_id', sa.Integer(), nullable=False), 43 | sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text(u'now()'), nullable=False), 44 | sa.PrimaryKeyConstraint('id') 45 | ) 46 | op.create_table('user', 47 | sa.Column('id', sa.Integer(), nullable=False), 48 | sa.Column('username', sa.String(length=80), nullable=False), 49 | sa.Column('photo_id', sa.String(length=36), nullable=True), 50 | sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text(u'now()'), nullable=False), 51 | sa.PrimaryKeyConstraint('id') 52 | ) 53 | 54 | op.create_foreign_key( 55 | 'credential_user_id_fkey', 56 | 'credential', 'user', 57 | ['user_id'], ['id'], 58 | ) 59 | op.create_foreign_key( 60 | 'photo_user_id_fkey', 61 | 'photo', 'user', 62 | ['user_id'], ['id'], 63 | ) 64 | op.create_foreign_key( 65 | 'post_photo_id_fkey', 66 | 'post', 'photo', 67 | ['photo_id'], ['id'], 68 | ) 69 | op.create_foreign_key( 70 | 'post_user_id_fkey', 71 | 'post', 'user', 72 | ['user_id'], ['id'], 73 | ) 74 | op.create_foreign_key( 75 | 'user_photo_id_fkey', 76 | 'user', 'photo', 77 | ['photo_id'], ['id'], 78 | ) 79 | # ### end Alembic commands ### 80 | 81 | 82 | def downgrade(): 83 | # ### commands auto generated by Alembic - please adjust! ### 84 | op.drop_table('user') 85 | op.drop_table('post') 86 | op.drop_table('photo') 87 | op.drop_table('credential') 88 | # ### end Alembic commands ### 89 | -------------------------------------------------------------------------------- /graygram/models/post.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sqlalchemy import select 4 | from sqlalchemy.ext.hybrid import hybrid_method 5 | from sqlalchemy.ext.hybrid import hybrid_property 6 | from sqlalchemy.sql import functions as sqlfuncs 7 | 8 | from graygram import cache 9 | from graygram import m 10 | from graygram.orm import db 11 | 12 | 13 | class Post(db.Model): 14 | id = db.Column(db.Integer, primary_key=True) 15 | 16 | photo_id = db.Column(db.String(36), db.ForeignKey('photo.id')) 17 | photo = db.relationship('Photo') 18 | 19 | message = db.Column(db.Text) 20 | 21 | user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, 22 | index=True) 23 | user = db.relationship('User') 24 | 25 | created_at = db.Column(db.DateTime(timezone=True), nullable=False, 26 | server_default=sqlfuncs.now(), index=True) 27 | 28 | def __repr__(self): 29 | return '' % self.id 30 | 31 | @hybrid_property 32 | @cache.memoize(timeout=300) 33 | def like_count(self): 34 | return m.PostLike.query.filter_by(post_id=self.id).count() 35 | 36 | @like_count.expression 37 | def like_count(cls): 38 | return select([sqlfuncs.count(m.PostLike.user_id)]) \ 39 | .where(m.PostLike.post_id == cls.id) \ 40 | .label('count') 41 | 42 | @hybrid_method 43 | @cache.memoize(timeout=300) 44 | def is_liked_by(self, user): 45 | post_like = m.PostLike.query \ 46 | .filter_by(user_id=user.id, post_id=self.id) \ 47 | .first() 48 | return post_like is not None 49 | 50 | @is_liked_by.expression 51 | def is_liked_by(cls, user): 52 | return m.PostLike.query \ 53 | .filter_by(user_id=user.id, post_id=cls.id) \ 54 | .exists() 55 | 56 | @hybrid_property 57 | def is_liked(self): 58 | from flask_login import current_user 59 | if not current_user.is_authenticated: 60 | return False 61 | return self.is_liked_by(current_user) 62 | 63 | @is_liked.expression 64 | def is_liked(cls): 65 | from flask_login import current_user 66 | if not current_user.is_authenticated: 67 | return False 68 | return cls.is_liked_by(current_user) 69 | 70 | @is_liked.setter 71 | def is_liked(self, value): 72 | from flask_login import current_user 73 | if not current_user.is_authenticated: 74 | return 75 | if value: 76 | post_like = m.PostLike(user_id=current_user.id, post_id=self.id) 77 | db.session.add(post_like) 78 | else: 79 | post_like = m.PostLike.query \ 80 | .filter_by(user_id=current_user.id, post_id=self.id) \ 81 | .first() 82 | if post_like: 83 | db.session.delete(post_like) 84 | cache.delete_memoized('is_liked_by', self, current_user) 85 | cache.delete_memoized('like_count', self) 86 | 87 | def serialize(self): 88 | return { 89 | 'id': self.id, 90 | 'photo': self.photo, 91 | 'message': self.message, 92 | 'user': self.user, 93 | 'created_at': self.created_at, 94 | 'like_count': self.like_count, 95 | 'is_liked': self.is_liked, 96 | } 97 | -------------------------------------------------------------------------------- /graygram/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | 5 | from werkzeug._compat import text_type 6 | from werkzeug.exceptions import HTTPException as _HTTPException 7 | 8 | 9 | class HTTPException(_HTTPException): 10 | 11 | status_code = None 12 | message = None 13 | field = None 14 | response = None 15 | 16 | def __init__(self, message=None, field=None): 17 | self.message = message or self.name 18 | self.field = field 19 | 20 | @property 21 | def code(self): 22 | return self.status_code 23 | 24 | def get_accept(self, environ): 25 | if environ is None: 26 | return 'text/html' 27 | return environ.get('HTTP_ACCEPT', 'text/html') 28 | 29 | def get_headers(self, environ=None): 30 | accept = self.get_accept(environ) 31 | if 'application/json' in accept: 32 | return [('Content-Type', 'application/json')] 33 | else: 34 | return super(HTTPException, self).get_headers(environ) 35 | 36 | def get_body(self, environ=None): 37 | accept = self.get_accept(environ) 38 | if 'application/json' in accept: 39 | return text_type(self.get_json_body()) 40 | else: 41 | return text_type(self.get_html_body()) 42 | 43 | def get_json_body(self): 44 | data = { 45 | 'error': { 46 | 'message': self.message, 47 | 'field': self.field, 48 | } 49 | } 50 | return json.dumps(data) 51 | 52 | def get_html_body(self): 53 | if self.field is not None: 54 | template = '

{status_code} {name}

{field}: {message}

' 55 | else: 56 | template = '

{status_code} {name}

{message}

' 57 | return template.format(status_code=self.status_code, 58 | name=self.name, 59 | field=self.field, 60 | message=self.message) 61 | 62 | 63 | class BadRequest(HTTPException): 64 | status_code = 400 65 | 66 | 67 | class Unauthorized(HTTPException): 68 | status_code = 401 69 | 70 | 71 | class Forbidden(HTTPException): 72 | status_code = 403 73 | 74 | 75 | class NotFound(HTTPException): 76 | status_code = 404 77 | 78 | 79 | class MethodNotAllowed(HTTPException): 80 | status_code = 405 81 | 82 | 83 | class NotAcceptable(HTTPException): 84 | status_code = 406 85 | 86 | 87 | class RequestTimeout(HTTPException): 88 | status_code = 408 89 | 90 | 91 | class Conflict(HTTPException): 92 | status_code = 409 93 | 94 | 95 | class Gone(HTTPException): 96 | status_code = 410 97 | 98 | 99 | class LengthRequired(HTTPException): 100 | status_code = 411 101 | 102 | 103 | class PreconditionFailed(HTTPException): 104 | status_code = 412 105 | 106 | 107 | class RequestEntityTooLarge(HTTPException): 108 | status_code = 413 109 | 110 | 111 | class RequestURITooLarge(HTTPException): 112 | status_code = 414 113 | 114 | 115 | class UnsupportedMediaType(HTTPException): 116 | status_code = 415 117 | 118 | 119 | class RequestedRangeNotSatisfiable(HTTPException): 120 | status_code = 416 121 | 122 | 123 | class ExpectationFailed(HTTPException): 124 | status_code = 417 125 | 126 | 127 | class ImATeapot(HTTPException): 128 | status_code = 418 129 | 130 | 131 | class UnprocessableEntity(HTTPException): 132 | status_code = 422 133 | 134 | 135 | class Locked(HTTPException): 136 | status_code = 423 137 | 138 | 139 | class PreconditionRequired(HTTPException): 140 | status_code = 428 141 | 142 | 143 | class TooManyRequests(HTTPException): 144 | status_code = 429 145 | 146 | 147 | class RequestHeaderFieldsTooLarge(HTTPException): 148 | status_code = 431 149 | 150 | 151 | class UnavailableForLegalReasons(HTTPException): 152 | status_code = 451 153 | 154 | 155 | class InternalServerError(HTTPException): 156 | status_code = 500 157 | 158 | 159 | class NotImplemented(HTTPException): 160 | status_code = 501 161 | 162 | 163 | class BadGateway(HTTPException): 164 | status_code = 502 165 | 166 | 167 | class ServiceUnavailable(HTTPException): 168 | status_code = 503 169 | 170 | 171 | class GatewayTimeout(HTTPException): 172 | status_code = 504 173 | 174 | 175 | class HTTPVersionNotSupported(HTTPException): 176 | status_code = 505 177 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Graygram documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Feb 22 01:40:07 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | import sphinx_rtd_theme 23 | 24 | sys.path.insert(0, os.path.abspath('..')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinxcontrib.httpdomain', 37 | 'sphinxcontrib.autohttp.flask', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'Graygram' 54 | copyright = u'2017, Suyeol Jeon' 55 | author = u'Suyeol Jeon' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = u'' 63 | # The full version, including alpha/beta/rc tags. 64 | release = u'' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This patterns also effect to html_static_path and html_extra_path 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = 'sphinx' 80 | 81 | # If true, `todo` and `todoList` produce output, else they produce nothing. 82 | todo_include_todos = False 83 | 84 | 85 | # -- Options for HTML output ---------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | html_theme = 'sphinx_rtd_theme' 91 | 92 | # Theme options are theme-specific and customize the look and feel of a theme 93 | # further. For a list of options available for each theme, see the 94 | # documentation. 95 | # 96 | # html_theme_options = {} 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 102 | 103 | 104 | # -- Options for HTMLHelp output ------------------------------------------ 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'Graygramdoc' 108 | 109 | 110 | # -- Options for LaTeX output --------------------------------------------- 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'Graygram.tex', u'Graygram Documentation', 135 | u'Suyeol Jeon', 'manual'), 136 | ] 137 | 138 | 139 | # -- Options for manual page output --------------------------------------- 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [ 144 | (master_doc, 'graygram', u'Graygram Documentation', 145 | [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | (master_doc, 'Graygram', u'Graygram Documentation', 156 | author, 'Graygram', 'One line description of project.', 157 | 'Miscellaneous'), 158 | ] 159 | -------------------------------------------------------------------------------- /tests/views/test_posts.py: -------------------------------------------------------------------------------- 1 | def test_get_post__failure__404(api): 2 | r = api.get('/posts/1') 3 | assert r.status_code == 404 4 | 5 | 6 | def test_get_post__success(api, post_tower_eiffel): 7 | post_id = post_tower_eiffel.id 8 | r = api.get('/posts/{}'.format(post_id)) 9 | assert r.status_code == 200 10 | assert 'tower_eiffel' in r.json['message'] 11 | 12 | 13 | def test_create_post__failure__not_logged_in(api): 14 | r = api.post('/posts') 15 | assert r.status_code == 401 16 | 17 | 18 | def test_create_post__failure__invalid_image(api, login, user_ironman): 19 | login(user_ironman) 20 | r = api.post('/posts', data={'photo': open('README.md')}) 21 | assert r.status_code == 400 22 | assert r.json['error']['field'] == 'photo' 23 | assert 'invalid image' in r.json['error']['message'].lower() 24 | 25 | 26 | def test_create_post__failure__missing_photo(api, login, user_ironman): 27 | login(user_ironman) 28 | r = api.post('/posts') 29 | assert r.status_code == 400 30 | assert r.json['error']['field'] == 'photo' 31 | assert 'missing' in r.json['error']['message'].lower() 32 | 33 | 34 | def test_create_post__success__photo_only(api, login, user_ironman): 35 | login(user_ironman) 36 | r = api.post('/posts', data={ 37 | 'photo': open('./tests/images/tower_eiffel.jpg') 38 | }) 39 | assert r.status_code == 201 40 | 41 | 42 | def test_create_post__success__photo_and_message(api, login, user_ironman): 43 | login(user_ironman) 44 | r = api.post('/posts', data={ 45 | 'photo': open('./tests/images/tower_eiffel.jpg'), 46 | 'message': 'I love Tower Eiffel', 47 | }) 48 | assert r.status_code == 201 49 | 50 | 51 | def test_update_post__failure__not_logged_in(api, post_tower_eiffel): 52 | post_id = post_tower_eiffel.id 53 | r = api.put('/posts/{}'.format(post_id)) 54 | assert r.status_code == 401 55 | 56 | 57 | def test_update_post__failure__not_mine(api, login, 58 | post_espresso, user_ironman): 59 | login(user_ironman) 60 | post_id = post_espresso.id 61 | r = api.put('/posts/{}'.format(post_id)) 62 | assert r.status_code == 403 63 | 64 | 65 | def test_update_post__failure__404(api, login, 66 | post_tower_eiffel, user_ironman): 67 | login(user_ironman) 68 | post_id = post_tower_eiffel.id + 1 69 | r = api.put('/posts/{}'.format(post_id)) 70 | assert r.status_code == 404 71 | 72 | 73 | def test_update_post__success(api, login, post_tower_eiffel, user_ironman): 74 | login(user_ironman) 75 | post_id = post_tower_eiffel.id 76 | r = api.put('/posts/{}'.format(post_id), data={ 77 | 'message': 'New message :)', 78 | }) 79 | assert r.status_code == 200 80 | assert r.json['message'] == 'New message :)' 81 | 82 | 83 | def test_delete_post__failure__not_logged_in(api, post_tower_eiffel): 84 | post_id = post_tower_eiffel.id 85 | r = api.delete('/posts/{}'.format(post_id)) 86 | assert r.status_code == 401 87 | 88 | 89 | def test_delete_post__failure__not_mine(api, login, 90 | post_espresso, user_ironman): 91 | login(user_ironman) 92 | post_id = post_espresso.id 93 | r = api.delete('/posts/{}'.format(post_id)) 94 | assert r.status_code == 403 95 | 96 | 97 | def test_delete_post__failure__404(api, login, 98 | post_tower_eiffel, user_ironman): 99 | login(user_ironman) 100 | post_id = post_tower_eiffel.id + 1 101 | r = api.delete('/posts/{}'.format(post_id)) 102 | assert r.status_code == 404 103 | 104 | 105 | def test_delete_post__success(api, login, post_tower_eiffel, user_ironman): 106 | login(user_ironman) 107 | post_id = post_tower_eiffel.id 108 | r = api.delete('/posts/{}'.format(post_id)) 109 | assert r.status_code == 200 110 | 111 | 112 | def test_get_post_likes__failure__404(api, post_tower_eiffel): 113 | post_id = post_tower_eiffel.id + 1 114 | r = api.get('/posts/{}/likes'.format(post_id)) 115 | assert r.status_code == 404 116 | 117 | 118 | def test_get_post_likes__success__empty(api, post_tower_eiffel): 119 | post_id = post_tower_eiffel.id 120 | r = api.get('/posts/{}/likes'.format(post_id)) 121 | assert r.status_code == 200 122 | assert len(r.json['data']) == 0 123 | 124 | 125 | def test_get_post_likes__success(api, login, post_espresso, user_ironman): 126 | login(user_ironman) 127 | post_id = post_espresso.id 128 | api.post('/posts/{}/likes'.format(post_id)) 129 | r = api.get('/posts/{}/likes'.format(post_id)) 130 | assert r.status_code == 200 131 | assert len(r.json['data']) == 1 132 | assert r.json['data'][0]['user']['username'] == 'ironman' 133 | 134 | 135 | def test_like_post__failure__not_logged_in(api, post_tower_eiffel): 136 | post_id = post_tower_eiffel.id 137 | r = api.post('/posts/{}/likes'.format(post_id)) 138 | assert r.status_code == 401 139 | 140 | 141 | def test_like_post__failure__404(api, login, post_espresso, user_ironman): 142 | login(user_ironman) 143 | post_id = post_espresso.id + 1 144 | r = api.post('/posts/{}/likes'.format(post_id)) 145 | assert r.status_code == 404 146 | 147 | 148 | def test_like_post__failure__conflict(api, login, post_espresso, user_ironman): 149 | login(user_ironman) 150 | post_id = post_espresso.id 151 | api.post('/posts/{}/likes'.format(post_id)) 152 | r = api.post('/posts/{}/likes'.format(post_id)) 153 | assert r.status_code == 409 154 | 155 | 156 | def test_like_post__success__mine(api, login, post_tower_eiffel, user_ironman): 157 | login(user_ironman) 158 | post_id = post_tower_eiffel.id 159 | r = api.post('/posts/{}/likes'.format(post_id)) 160 | assert r.status_code == 201 161 | r = api.get('/posts/{}'.format(post_id)) 162 | assert r.json['like_count'] == 1 163 | 164 | 165 | def test_like_post__success__others(api, login, post_espresso, user_ironman): 166 | login(user_ironman) 167 | post_id = post_espresso.id 168 | r = api.post('/posts/{}/likes'.format(post_id)) 169 | assert r.status_code == 201 170 | r = api.get('/posts/{}'.format(post_id)) 171 | assert r.json['like_count'] == 1 172 | 173 | 174 | def test_unlike_post__failure__not_logged_in(api, post_tower_eiffel): 175 | post_id = post_tower_eiffel.id 176 | r = api.delete('/posts/{}/likes'.format(post_id)) 177 | assert r.status_code == 401 178 | 179 | 180 | def test_unlike_post__failure__404(api, login, post_espresso, user_ironman): 181 | login(user_ironman) 182 | post_id = post_espresso.id + 1 183 | r = api.delete('/posts/{}/likes'.format(post_id)) 184 | assert r.status_code == 404 185 | 186 | 187 | def test_unlike_post__failure__conflict(api, login, 188 | post_espresso, user_ironman): 189 | login(user_ironman) 190 | post_id = post_espresso.id 191 | r = api.delete('/posts/{}/likes'.format(post_id)) 192 | assert r.status_code == 409 193 | 194 | 195 | def test_unlike_post__success__mine(api, login, 196 | post_tower_eiffel, user_ironman): 197 | login(user_ironman) 198 | post_id = post_tower_eiffel.id 199 | api.post('/posts/{}/likes'.format(post_id)) 200 | r = api.delete('/posts/{}/likes'.format(post_id)) 201 | assert r.status_code == 200 202 | r = api.get('/posts/{}'.format(post_id)) 203 | assert r.json['like_count'] == 0 204 | 205 | 206 | def test_unlike_post__success__others(api, login, 207 | post_espresso, user_ironman): 208 | login(user_ironman) 209 | post_id = post_espresso.id 210 | api.post('/posts/{}/likes'.format(post_id)) 211 | r = api.delete('/posts/{}/likes'.format(post_id)) 212 | assert r.status_code == 200 213 | r = api.get('/posts/{}'.format(post_id)) 214 | assert r.json['like_count'] == 0 215 | -------------------------------------------------------------------------------- /graygram/views/api/posts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import Blueprint 4 | from flask import request 5 | from flask_login import current_user 6 | from flask_login import login_required 7 | 8 | from graygram import cache 9 | from graygram import m 10 | from graygram.exceptions import BadRequest 11 | from graygram.exceptions import Conflict 12 | from graygram.exceptions import Forbidden 13 | from graygram.orm import db 14 | from graygram.paging import next_url 15 | from graygram.photo_uploader import InvalidImage 16 | from graygram.renderers import render_json 17 | 18 | 19 | view = Blueprint('api.posts', __name__, url_prefix='/posts') 20 | 21 | 22 | @view.route('/') 23 | def get_post(post_id): 24 | """Get a single post. 25 | 26 | **Example JSON response**: 27 | 28 | .. sourcecode:: json 29 | 30 | { 31 | "message": "생 마르탱 운하에서 본 할머니와 할아버지", 32 | "id": 14, 33 | "is_liked": false, 34 | "like_count": 0, 35 | "user": { 36 | "created_at": "2017-02-07T09:23:13+0000", 37 | "photo": { 38 | "id": "d7394923-cf39-4c78-891a-8714eb615ea7" 39 | }, 40 | "username": "devxoul", 41 | "id": 1 42 | }, 43 | "created_at": "2017-01-27T19:52:32+0000", 44 | "photo": { 45 | "id": "69e892b4-7dbd-403c-92fc-07f199c2be35" 46 | } 47 | } 48 | 49 | :>json int id: 50 | :>json Photo photo: 51 | :>json string message: A message (Optional) 52 | :>json User user: Post author 53 | :>json date created_at: 54 | 55 | :statuscode 200: OK 56 | :statuscode 404: There's no post 57 | """ 58 | 59 | post = m.Post.query.get_or_404(post_id) 60 | return render_json(post) 61 | 62 | 63 | @view.route('', methods=['POST']) 64 | @login_required 65 | def create_post(): 66 | """Create a post 67 | 68 | **Example JSON response**: 69 | 70 | .. sourcecode:: json 71 | 72 | { 73 | "message": "생 마르탱 운하에서 본 할머니와 할아버지", 74 | "id": 14, 75 | "is_liked": false, 76 | "like_count": 0, 77 | "user": { 78 | "created_at": "2017-02-07T09:23:13+0000", 79 | "photo": { 80 | "id": "d7394923-cf39-4c78-891a-8714eb615ea7" 81 | }, 82 | "username": "devxoul", 83 | "id": 1 84 | }, 85 | "created_at": "2017-01-27T19:52:32+0000", 86 | "photo": { 87 | "id": "69e892b4-7dbd-403c-92fc-07f199c2be35" 88 | } 89 | } 90 | 91 | :form photo: An image file 92 | :form message: A message (Optional) 93 | :statuscode 201: Created 94 | :statuscode 400: Required parameter is missing or invalid 95 | :statuscode 401: Not authorized 96 | """ 97 | 98 | if 'photo' not in request.files: 99 | raise BadRequest(message="Missing parameter", field='photo') 100 | try: 101 | photo = m.Photo.upload(file=request.files['photo']) 102 | except InvalidImage: 103 | raise BadRequest(message="Invalid image", field='photo') 104 | 105 | message = request.values.get('message') 106 | 107 | post = m.Post() 108 | post.user = current_user 109 | post.photo = photo 110 | post.message = message 111 | db.session.add(post) 112 | db.session.commit() 113 | return render_json(post), 201 114 | 115 | 116 | @view.route('/', methods=['PUT', 'PATCH']) 117 | @login_required 118 | def update_post(post_id): 119 | """Edit the post 120 | 121 | **Example JSON response**: 122 | 123 | .. sourcecode:: json 124 | 125 | { 126 | "message": "생 마르탱 운하에서 본 할머니와 할아버지", 127 | "id": 14, 128 | "is_liked": false, 129 | "like_count": 0, 130 | "user": { 131 | "created_at": "2017-02-07T09:23:13+0000", 132 | "photo": { 133 | "id": "d7394923-cf39-4c78-891a-8714eb615ea7" 134 | }, 135 | "username": "devxoul", 136 | "id": 1 137 | }, 138 | "created_at": "2017-01-27T19:52:32+0000", 139 | "photo": { 140 | "id": "69e892b4-7dbd-403c-92fc-07f199c2be35" 141 | } 142 | } 143 | 144 | :form message: New message (Optional) 145 | :statuscode 200: OK 146 | :statuscode 401: Not authorized 147 | :statuscode 403: No permission 148 | """ 149 | post = m.Post.query.get_or_404(post_id) 150 | if post.user != current_user: 151 | raise Forbidden() 152 | post.message = request.values.get('message') 153 | db.session.add(post) 154 | db.session.commit() 155 | return render_json(post) 156 | 157 | 158 | @view.route('/', methods=['DELETE']) 159 | @login_required 160 | def delete_post(post_id): 161 | """ Delete the post 162 | 163 | **Example JSON response**: 164 | 165 | .. sourcecode:: json 166 | 167 | {} 168 | 169 | :statuscode 200: Deleted 170 | :statuscode 401: Not authorized 171 | :statuscode 403: No permission 172 | """ 173 | post = m.Post.query.get_or_404(post_id) 174 | if post.user != current_user: 175 | raise Forbidden() 176 | db.session.delete(post) 177 | db.session.commit() 178 | return render_json({}) 179 | 180 | 181 | @view.route('//likes') 182 | def get_likes(post_id): 183 | """Get users who like the post 184 | 185 | **Example JSON response**: 186 | 187 | .. sorucecode:: json 188 | 189 | { 190 | "data": [ 191 | { 192 | "username": "devxoul", 193 | "photo": null, 194 | "created_at": "2017-02-21T16:59:35+0000", 195 | "id": 1 196 | } 197 | ], 198 | "paging": { 199 | "next": null 200 | } 201 | } 202 | 203 | :statuscode 200: OK 204 | :statuscode 404: There's no post 205 | """ 206 | post = m.Post.query.get_or_404(post_id) 207 | limit = request.values.get('limit', 30, type=int) 208 | offset = request.values.get('offset', 0, type=int) 209 | post_likes = m.PostLike.query \ 210 | .filter_by(post=post) \ 211 | .order_by(m.PostLike.liked_at.desc()) \ 212 | .offset(offset) \ 213 | .limit(limit) 214 | data = { 215 | 'data': [post_like.serialize() for post_like in post_likes], 216 | 'paging': None, 217 | } 218 | if limit + offset < m.Post.query.count(): 219 | data['paging'] = { 220 | 'next': next_url(limit=limit, offset=offset), 221 | } 222 | return render_json(data) 223 | 224 | 225 | @view.route('//likes', methods=['POST']) 226 | @login_required 227 | def like_post(post_id): 228 | """Like the post 229 | 230 | **Example JSON response**: 231 | 232 | .. sourcecode:: json 233 | 234 | {} 235 | 236 | :statuscode 201: Liked 237 | :statuscode 401: Not authorized 238 | :statuscode 409: Already liked 239 | """ 240 | post = m.Post.query.get_or_404(post_id) 241 | cache.delete_memoized('is_liked_by', post, current_user) 242 | if post.is_liked: 243 | raise Conflict() 244 | post.is_liked = True 245 | db.session.add(post) 246 | db.session.commit() 247 | return render_json({}), 201 248 | 249 | 250 | @view.route('//likes', methods=['DELETE']) 251 | @login_required 252 | def unlike_post(post_id): 253 | """Unlike the post 254 | 255 | **Example JSON response**: 256 | 257 | .. sourcecode:: json 258 | 259 | {} 260 | 261 | :statuscode 200: Unliked 262 | :statuscode 401: Not authorized 263 | :statuscode 409: Already unliked 264 | """ 265 | post = m.Post.query.get_or_404(post_id) 266 | cache.delete_memoized('is_liked_by', post, current_user) 267 | if not post.is_liked: 268 | raise Conflict() 269 | post.is_liked = False 270 | db.session.add(post) 271 | db.session.commit() 272 | return render_json({}) 273 | --------------------------------------------------------------------------------