diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8d283d2e8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +quokka/themes/* linguist-vendored diff --git a/.gitignore b/.gitignore index 504e7eee9..995c8e4df 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ etc/mongodata/*.0 etc/mongodata/*.ns etc/mongodata/*.lock etc/mongodata/*.bson -etc/logs/*.log \ No newline at end of file +*.log +*.pid \ No newline at end of file diff --git a/.landscape.yaml b/.landscape.yaml index a6de678b3..97643e656 100644 --- a/.landscape.yaml +++ b/.landscape.yaml @@ -1,3 +1,10 @@ +# python-targets: +# - 2 +# - 3 + +# uses: +# - flask + pylint: disable: - bare-except @@ -28,4 +35,9 @@ requirements: - requirements/requirements.txt - requirements/test.txt -#max-line-length: 120 + +# doc-warnings: yes +# test-warnings: no +# strictness: veryhigh +# max-line-length: 120 +# autodetect: yes diff --git a/Dockerfile b/Dockerfile index 992f7c9fd..8ce36cfa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ COPY requirements/requirements.txt /tmp/ COPY requirements/test.txt /tmp/ COPY requirements/dev.txt /tmp/ RUN apk update -RUN apk add gcc python py-pip libjpeg zlib zlib-dev tiff freetype git py-pillow python-dev musl-dev bash +RUN apk add gcc python py-pip jpeg libjpeg jpeg-dev zlib zlib-dev tiff freetype git py-pillow python-dev musl-dev bash RUN pip install -r /tmp/requirements.txt RUN pip install -r /tmp/test.txt RUN pip install -r /tmp/dev.txt diff --git a/LICENSE b/LICENSE index b8c182c9c..59a741f1a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Licensed under MIT license. http://opensource.org/licenses/MIT -Copyright (c) 2015 Quokka Project. and other contributors +Copyright (c) 2013-2016 Quokka Project. and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/Makefile b/Makefile index 3bb19f575..cbf209d00 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,20 @@ -.PHONY: run +.PHONY: run shell test install pep8 clean + run: python manage.py runserver --reloader --debug -.PHONY: shell shell: python manage.py shell -.PHONY: test test: pep8 - QUOKKA_MODE=test py.test --cov quokka + QUOKKA_MODE=test py.test --cov=quokka -l --tb=short --maxfail=1 quokka/ -.PHONY: install install: python setup.py develop -.PHONY: pep8 pep8: @flake8 quokka --ignore=F403 --exclude=migrations -.PHONY: sdist -sdist: test - @python setup.py sdist upload - -.PHONY: clean clean: @find ./ -name '*.pyc' -exec rm -f {} \; @find ./ -name 'Thumbs.db' -exec rm -f {} \; diff --git a/README.md b/README.md index 44a78a9c6..a827b78ce 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ [![Flask Registered](https://img.shields.io/badge/flask-registered-green.svg?style=flat)](https://github.com/pocoo/metaflask) -[![Travis CI](http://img.shields.io/travis/quokkaproject/quokka.svg)](https://travis-ci.org/quokkaproject/quokka) -[![Coverage Status](http://img.shields.io/coveralls/quokkaproject/quokka.svg)](https://coveralls.io/r/quokkaproject/quokka) -[![Code Health](https://landscape.io/github/quokkaproject/quokka/development/landscape.svg?style=flat)](https://landscape.io/github/quokkaproject/quokka/development) -[![Requirements Status](https://requires.io/github/quokkaproject/quokka/requirements.svg?branch=development)](https://requires.io/github/quokkaproject/quokka/requirements/?branch=development) +[![Travis CI](http://img.shields.io/travis/rochacbruno/quokka.svg)](https://travis-ci.org/rochacbruno/quokka) +[![Coverage Status](http://img.shields.io/coveralls/rochacbruno/quokka.svg)](https://coveralls.io/r/rochacbruno/quokka) +[![Code Health](https://landscape.io/github/rochacbruno/quokka/development/landscape.svg?style=flat)](https://landscape.io/github/rochacbruno/quokka/development) +[![Requirements Status](https://requires.io/github/rochacbruno/quokka/requirements.svg?branch=development)](https://requires.io/github/rochacbruno/quokka/requirements/?branch=development) -[![Stories in Ready](https://badge.waffle.io/quokkaproject/quokka.png?label=ready&title=Ready)](http://waffle.io/quokkaproject/quokka) +[![Stories in Ready](https://badge.waffle.io/rochacbruno/quokka.png?label=ready&title=Ready)](http://waffle.io/rochacbruno/quokka) [![Join Slack Chat](https://img.shields.io/badge/JOIN_SLACK-CHAT-green.svg)](https://quokkaslack.herokuapp.com/) [![Slack](http://quokkaslack.herokuapp.com/badge.svg)](https://quokkaproject.slack.com/messages/) @@ -13,7 +13,7 @@ Donate with Paypal [![wercker status](https://app.wercker.com/status/e9cbc4497ee946083aa19fbd3f756c91/m "wercker status")](https://app.wercker.com/project/bykey/e9cbc4497ee946083aa19fbd3f756c91) -[![Launch on OpenShift](http://launch-shifter.rhcloud.com/button.svg)](https://openshift.redhat.com/app/console/application_type/custom?cartridges%5B%5D=python-2.7&cartridges%5B%5D=mongodb-2.4&initial_git_url=https://github.com/quokkaproject/quokka.git&name=quokka&initial_git_branch=master) +[![Launch on OpenShift](http://launch-shifter.rhcloud.com/button.svg)](https://openshift.redhat.com/app/console/application_type/custom?cartridges%5B%5D=python-2.7&cartridges%5B%5D=mongodb-2.4&initial_git_url=https://github.com/rochacbruno/quokka.git&name=quokka&initial_git_branch=master) Quokka project =============================================== @@ -49,20 +49,22 @@ User 'admin@example.com' and passwd 'admin' to login in to /admin # Get Quokka -## Get Quokka Master and enter in to its root directory + +# Using Docker + +The easiest way to run Quokka for development or production is using quokkaCMS + Gunicorn + Supervisor under a Docker Container. You can see the instructions in the following repository: https://github.com/quokkaproject/docker-gunicorn-supervisor + + +## Get Quokka to run locally for development or deployment ```bash -git clone https://github.com/quokkaproject/quokka --branch master --single-branch +git clone https://github.com/rochacbruno/quokka --branch master --single-branch cd quokka ``` > if you are cloning to contribute to the project just clone it without the "--branch=master --single-branch" part -## Run Quokka - -You have 2 options **RUN LOCAL** or **RUN IN DOCKER** - -### RUN LOCAL +### Run Quokka Install needed packages in your local computer @@ -94,6 +96,8 @@ You can install everything you need in your local computer or if preferred use a MONGODB_USERNAME = None MONGODB_PASSWORD = None ============================================================= + + # You can also use envvars `export QUOKKA_MONGO_DB="yourdbname"` ``` 3. If you have Docker installed you can simply run the official Mongo image @@ -117,7 +121,8 @@ You can install everything you need in your local computer or if preferred use a #### Python requirements Install all needed python packages -> activate your virtualenv if you want + +> If you have a virtualenv, activate it! `source env/bin/activate` or `workon env` ```bash pip install -r requirements/requirements.txt @@ -135,7 +140,7 @@ pip install -r requirements/requirements.txt P4$$W0Rd ``` - 4. Populate with sample data (optional if you want sample data) + 4. Populate with sample data (optional if you want sample data for testing) ```bash $ python manage.py populate @@ -148,68 +153,7 @@ pip install -r requirements/requirements.txt ``` - Site on [http://localhost:5000](http://localhost:5000) - Admin on [http://localhost:5000/admin](http://localhost:5000/admin) - - -### RUN IN DOCKER - -- Run pre built docker images with everything pre-installed -- You will need docker and docker compose installed in your machine -- Once in Docker all data is stored behind quokka/etc folder - - -#### Install Docker and docker-compose - -- **Docker** - https://docs.docker.com/installation/ -- **Docker-Compose** - https://docs.docker.com/compose/install/ - - -> Ensure that local port 27017(mongo) is not being used on your computer - -* ### Run with docker-compose - - 1. Easiest way is just running the following command in quokka root folder - ```bash - docker-compose up - ``` - - > use -d on above to leave it as a daemon - - 2. You can create a new admin user to login and start posting - ```bash - docker-compose run web python manage.py accounts_createsuperuser - ``` - - 3. Or populate with sample data (optional) - ```bash - docker-compose run web python manage.py populate - ``` - > credentials for /admin will be email: admin@example.com passwd: admin - - 4. You enter Quokka Shell (in a separate console) or run any other command in place of **shell* - ```bash - docker-compose run web python manage.py shell - ``` - -* ### Run standalone containers -> (each in separate shells or use -d option) - - 1. run mongo container - ```bash - docker run -v $PWD/etc/mongodata:/data/db -p 27017:27017 --name mongo mongo - ``` - - 2. run quokka web app container - ```bash - docker run -e "QUOKKA_MONGODB_HOST=mongo" -p 5000:5000 --link mongo:mongo -v $PWD:/quokka --workdir /quokka -t -i quokka/quokkadev python manage.py runserver --host 0.0.0.0 - - ``` - - 3. run quokka shell if needed - ```bash - docker run -e "QUOKKA_MONGODB_HOST=mongo" -p 5000:5000 --link mongo:mongo -v $PWD:/quokka --workdir /quokka -t -i quokka/quokkadev python manage.py shell - - ``` - + ## Deployment @@ -223,18 +167,18 @@ http://quokkaproject.org/documentation > If you want to help writing the docs please go to https://github.com/quokkaproject/quokkaproject.github.io -Also there is a [Wiki](https://github.com/quokkaproject/quokka/wiki) +Also there is a [Wiki](https://github.com/rochacbruno/quokka/wiki) =============================================== > NOTE: the content from wiki will be moved to /documentation -* [About & Features](https://github.com/quokkaproject/quokka/wiki/about) -* [Installing and running](https://github.com/quokkaproject/quokka/wiki/installation) -* [Requirements](https://github.com/quokkaproject/quokka/wiki/requirements) -* [Extending & Installing modules](https://github.com/quokkaproject/quokka/wiki/plugins) -* [Admin interface](https://github.com/quokkaproject/quokka/wiki/screencast) -* [Project tree](https://github.com/quokkaproject/quokka/wiki/project-tree) -* [Team & Committers](https://github.com/quokkaproject/quokka/graphs/contributors) +* [About & Features](https://github.com/rochacbruno/quokka/wiki/about) +* [Installing and running](https://github.com/rochacbruno/quokka/wiki/installation) +* [Requirements](https://github.com/rochacbruno/quokka/wiki/requirements) +* [Extending & Installing modules](https://github.com/rochacbruno/quokka/wiki/plugins) +* [Admin interface](https://github.com/rochacbruno/quokka/wiki/screencast) +* [Project tree](https://github.com/rochacbruno/quokka/wiki/project-tree) +* [Team & Committers](https://github.com/rochacbruno/quokka/graphs/contributors) Hosting @@ -245,7 +189,7 @@ You can host a Quokka website in any VPS or cloud which supports Python and Flas - PythonAnywhere can run Quokka with Mongo hosted at MongoLab - DigitalOcean is a good option for a VPS - Jelastic Cloud has the easiest Quokka deployment - http://docs.jelastic.com/ru/quokka-cms -- OpenShift [one click deploy](https://openshift.redhat.com/app/console/application_type/custom?cartridges%5B%5D=python-2.7&cartridges%5B%5D=mongodb-2.4&initial_git_url=https://github.com/quokkaproject/quokka.git&name=quokka&initial_git_branch=master) +- OpenShift [one click deploy](https://openshift.redhat.com/app/console/application_type/custom?cartridges%5B%5D=python-2.7&cartridges%5B%5D=mongodb-2.4&initial_git_url=https://github.com/rochacbruno/quokka.git&name=quokka&initial_git_branch=master) ![python](docs/python_powered.png) diff --git a/docker-compose.yml b/docker-compose.yml index 57d527996..0ed09d9ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,15 +5,6 @@ mongo: volumes: - ./etc/mongodata:/data/db -# elastic: -# image: catholabs/elastic-with-marvel -# command: elasticsearch -# volumes: -# - etc/elasticdata:/usr/share/elasticsearch/data -# ports: -# - "9200:9200" -# mem_limit: 1000000000 - web: restart: always image: quokka/quokkadev @@ -24,7 +15,6 @@ web: - .:/quokka links: - mongo:mongo - # - elastic:elastic command: sh etc/docker_wait_to_start.sh environment: - QUOKKA_MONGODB_HOST=mongo diff --git a/etc/fixtures/demo_data.json b/etc/fixtures/demo_data.json index 26c0e7765..171fe253f 100644 --- a/etc/fixtures/demo_data.json +++ b/etc/fixtures/demo_data.json @@ -31,6 +31,38 @@ "order" : 0 } ] + }, + { + "name" : "author", + "email" : "author@example.com", + "gravatar_email": "rochacbruno+quokka@gmail.com", + "password" : "author", + "roles" : [ "author" ], + "bio": "Quokka Example Author", + "tagline": "It is nice to write in Quokka CMS!", + "links": [ + { + "title" : "facebook", + "link" : "http://facebook.com/quokkaproject", + "icon" : "facebook", + "css_class" : "facebook", + "order" : 0 + }, + { + "title" : "github", + "link" : "http://github.com/quokkaproject", + "icon" : "github", + "css_class" : "github", + "order" : 0 + }, + { + "title" : "twitter", + "link" : "http://twitter.com/quokkaproject", + "icon" : "twitter", + "css_class" : "twitter", + "order" : 0 + } + ] } ], "configs": [ diff --git a/etc/fixtures/initial_data.json b/etc/fixtures/initial_data.json index d8d178609..ca048991e 100644 --- a/etc/fixtures/initial_data.json +++ b/etc/fixtures/initial_data.json @@ -31,6 +31,38 @@ "order" : 0 } ] + }, + { + "name" : "author", + "email" : "author@example.com", + "gravatar_email": "rochacbruno+quokka@gmail.com", + "password" : "author", + "roles" : [ "author" ], + "bio": "Quokka Example Author", + "tagline": "It is nice to write in Quokka CMS!", + "links": [ + { + "title" : "facebook", + "link" : "http://facebook.com/quokkaproject", + "icon" : "facebook", + "css_class" : "facebook", + "order" : 0 + }, + { + "title" : "github", + "link" : "http://github.com/quokkaproject", + "icon" : "github", + "css_class" : "github", + "order" : 0 + }, + { + "title" : "twitter", + "link" : "http://twitter.com/quokkaproject", + "icon" : "twitter", + "css_class" : "twitter", + "order" : 0 + } + ] } ], "configs": [ diff --git a/etc/openshift_clean.py b/etc/openshift_clean.py index ecc031354..a05d4c5f5 100644 --- a/etc/openshift_clean.py +++ b/etc/openshift_clean.py @@ -5,7 +5,9 @@ OR AT YOUR OWN RISK!!!! """ from quokka import create_app -from quokka.core.models import Content, Channel, Config +from quokka.core.models.content import Content +from quokka.core.models.config import Config +from quokka.core.models.channel import Channel from quokka.modules.accounts.models import User app = create_app() diff --git a/etc/openshift_settings.py b/etc/openshift_settings.py index 030617eaf..809c396eb 100644 --- a/etc/openshift_settings.py +++ b/etc/openshift_settings.py @@ -24,6 +24,7 @@ LOGGER_FORMAT = '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' LOGGER_DATE_FORMAT = '%d.%m %H:%M:%S' +DEBUG = False if os.environ['OPENSHIFT_APP_NAME'] == 'quokkadevelopment': DEBUG_TOOLBAR_ENABLED = True DEBUG = True @@ -33,7 +34,6 @@ MAP_STATIC_ROOT = ( '/robots.txt', - '/sitemap.xml', '/favicon.ico', '/vaddy-c603c78bbeba8d9.html' ) @@ -51,3 +51,11 @@ "" "" ) + +OPBEAT = { + 'ORGANIZATION_ID': '87883697e9f64e85b1a8170ff664890a', + 'APP_ID': 'd924873719', + 'SECRET_TOKEN': 'a0fee12cdb9eca36320b42d2d50f57ceea260dc5', + 'INCLUDE_PATHS': ['quokka'], + 'DEBUG': DEBUG, +} diff --git a/manage.py b/manage.py index c676b8055..28d66b8eb 100755 --- a/manage.py +++ b/manage.py @@ -72,6 +72,19 @@ def populate(filename, baseurl=None): Populate(db, filepath=filename, baseurl=baseurl, app=app)() +@core_cmd.command() +@click.option( + '-f', + '--filename', + help='Fixtures JSON path', + default='./etc/fixtures/initial_data.json') +@click.option('-b', '--baseurl', help='base url to use', default=None) +def populate_reset(filename, baseurl=None): + """De-Populate the database with sample data""" + from quokka.utils.populate import Populate + Populate(db, filepath=filename, baseurl=baseurl, app=app).reset() + + @core_cmd.command() def showconfig(): """Print all config variables""" diff --git a/quokka/__init__.py b/quokka/__init__.py index 84b836be4..8476003b0 100644 --- a/quokka/__init__.py +++ b/quokka/__init__.py @@ -1,23 +1,11 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -VERSION = (0, 2, 0) +from quokka.core.admin import create_admin +from quokka.core.app import QuokkaApp +from quokka.core.middleware import HTTPMethodOverrideMiddleware +from quokka.ext import configure_extensions -__version__ = ".".join(map(str, VERSION)) -__status__ = "Alpha" -__description__ = "Flexible & modular CMS powered by Flask and MongoDB" -__author__ = "Bruno Rocha " -__email__ = "quokka-developers@googlegroups.com" -__license__ = "MIT License" -__copyright__ = "Copyright 2014, Quokka Project" - - -try: - from .core.admin import create_admin - from .core.app import QuokkaApp - # from .core.middleware import HTTPMethodOverrideMiddleware - admin = create_admin() -except: - pass +admin = create_admin() def create_app_base(config=None, test=False, admin_instance=None, **settings): @@ -32,9 +20,10 @@ def create_app(config=None, test=False, admin_instance=None, **settings): app = create_app_base( config=config, test=test, admin_instance=admin_instance, **settings ) - from .ext import configure_extensions + configure_extensions(app, admin_instance or admin) - # app.wsgi_app = HTTPMethodOverrideMiddleware(app.wsgi_app) + if app.config.get("HTTP_PROXY_METHOD_OVERRIDE"): + app.wsgi_app = HTTPMethodOverrideMiddleware(app.wsgi_app) return app diff --git a/quokka/core/__init__.py b/quokka/core/__init__.py index f794655c2..84b2307ee 100644 --- a/quokka/core/__init__.py +++ b/quokka/core/__init__.py @@ -1,3 +1,7 @@ # -*- coding: utf-8 -*- -TEXT_FORMATS = ("html", "markdown") +TEXT_FORMATS = ( + ("html", "html"), + ("markdown", "markdown"), + ("plaintext", "plaintext") +) diff --git a/quokka/core/admin/__init__.py b/quokka/core/admin/__init__.py index 9f7018ab0..25383045f 100644 --- a/quokka/core/admin/__init__.py +++ b/quokka/core/admin/__init__.py @@ -1,47 +1,41 @@ #!/usr/bin/env python # -*- coding: utf-8 -* - -import logging from werkzeug.utils import import_string + from flask import request, session from flask.ext.admin import Admin -from ..models import (Link, Config, SubContentPurpose, ChannelType, - ContentTemplateType, Channel) +from quokka.core.models.subcontent import SubContentPurpose +from quokka.core.models.config import Config +from quokka.core.models.channel import Channel, ChannelType +from quokka.core.models.content import Link, ContentTemplateType + +from quokka.utils.translation import _l, _n +from quokka.utils.settings import get_setting_value from .models import ModelAdmin, FileAdmin, BaseIndexView from .views import (IndexView, InspectorView, LinkAdmin, ConfigAdmin, SubContentPurposeAdmin, ChannelTypeAdmin, ContentTemplateTypeAdmin, ChannelAdmin) -from quokka.utils.translation import _l, _n -from quokka.utils.settings import get_setting_value - - ''' _n is here only for backwards compatibility, to be imported by 3rd party modules. The below _n below is to avoid pep8 error ''' _n # noqa -logger = logging.getLogger() - class QuokkaAdmin(Admin): + registered = [] + def register(self, model, view=None, *args, **kwargs): _view = view or ModelAdmin admin_view_exclude = get_setting_value('ADMIN_VIEW_EXCLUDE', []) identifier = '.'.join((model.__module__, model.__name__)) - if identifier not in admin_view_exclude: + if (identifier not in admin_view_exclude) and ( + identifier not in self.registered): self.add_view(_view(model, *args, **kwargs)) - # try: - # self.add_view(_view(model, *args, **kwargs)) - # except Exception as e: - # logger.warning( - # "admin.register({0}, {1}, {2}, {3}) error: {4}".format( - # model, view, args, kwargs, e.message - # ) - # ) + self.registered.append(identifier) def create_admin(app=None): @@ -87,8 +81,8 @@ def get_locale(): return session.get('lang', 'en') admin.locale_selector(get_locale) - except: - pass # Exception: Can not add locale_selector second time. + except Exception as e: + app.logger.info('Cannot add locale_selector. %s' % e) for entry in app.config.get('FILE_ADMIN', []): try: @@ -104,8 +98,7 @@ def get_locale(): ) ) except Exception as e: - logger.info(e) - # need to check blueprint endpoisnt colision + app.logger.info(e) # register all themes in file manager for k, theme in app.theme_manager.themes.items(): @@ -144,8 +137,12 @@ def get_locale(): 'DEFAULT_EDITABLE_EXTENSIONS') ) ) - except: - pass + except Exception as e: + app.logger.warning( + 'Error registering %s folder to file admin %s' % ( + theme.identifier, e + ) + ) # adding views admin.add_view(InspectorView(category=_l("Settings"), diff --git a/quokka/core/admin/fields.py b/quokka/core/admin/fields.py index c84d16170..904aeab64 100644 --- a/quokka/core/admin/fields.py +++ b/quokka/core/admin/fields.py @@ -6,9 +6,8 @@ from flask import current_app from flask.ext.admin import form from flask.ext.admin.form.upload import ImageUploadInput -from flask.ext.admin._compat import urljoin -from quokka.core.models import SubContent, SubContentPurpose +from quokka.core.models.subcontent import SubContent, SubContentPurpose from quokka.modules.media.models import Image if sys.version_info.major == 3: @@ -21,15 +20,18 @@ class ThumbWidget(ImageUploadInput): ' ' '') - def get_url(self, field): - if field.thumbnail_size: - filename = field.thumbnail_fn(field.data) - else: - filename = field.data - - if field.url_relative_path: - filename = urljoin(field.url_relative_path, filename) - + @staticmethod + def get_url(field): + ''' + This meethod is not used, but is here for compatibility + ''' + # if field.thumbnail_size: + # filename = field.thumbnail_fn(field.data) + # else: + # filename = field.data + # + # if field.url_relative_path: + # filename = urljoin(field.url_relative_path, filename) return field.data diff --git a/quokka/core/admin/models.py b/quokka/core/admin/models.py index 699ce2043..095a9e1bc 100644 --- a/quokka/core/admin/models.py +++ b/quokka/core/admin/models.py @@ -3,17 +3,17 @@ import random import datetime -from flask.ext.admin.contrib.mongoengine import ModelView -from flask.ext.admin.contrib.fileadmin import FileAdmin as _FileAdmin -from flask.ext.admin.babel import gettext, ngettext -from flask.ext.admin import AdminIndexView -from flask.ext.admin import BaseView as AdminBaseView -from flask.ext.admin.actions import action -from flask.ext.admin import helpers as h -from flask.ext.security import current_user -from flask.ext.security.utils import url_for_security from flask import redirect, flash, url_for, Response, current_app +from flask_admin.contrib.mongoengine import ModelView +from flask_admin.contrib.fileadmin import FileAdmin as _FileAdmin +from flask_admin.babel import gettext, ngettext +from flask_admin import AdminIndexView +from flask_admin import BaseView as AdminBaseView +from flask_admin.actions import action +from flask_admin import helpers as h +from flask_security import current_user +from flask_security.utils import url_for_security from flask.ext.htmlbuilder import html from quokka.modules.accounts.models import User @@ -23,9 +23,9 @@ from quokka.utils.upload import dated_path, lazy_media_path from quokka.utils import is_accessible from quokka.utils.settings import get_setting_value -from .fields import ThumbField -from .utils import _, _l, _n +from quokka.core.admin.fields import ThumbField +from quokka.core.admin.utils import _, _l, _n class ThemeMixin(object): @@ -39,7 +39,7 @@ def render(self, template, **kwargs): kwargs['h'] = h # Contribute extra arguments kwargs.update(self._template_args) - theme = current_app.config.get('ADMIN_THEME', None) + theme = current_app.config.get('ADMIN_THEME') return render_template(template, theme=theme, **kwargs) @@ -49,7 +49,7 @@ def is_accessible(self): roles_accepted = getattr(self, 'roles_accepted', None) return is_accessible(roles_accepted=roles_accepted, user=current_user) - def _handle_view(self, name, *args, **kwargs): + def _handle_view(self, *args, **kwargs): if not current_user.is_authenticated(): return redirect(url_for_security('login', next="/admin")) if not self.is_accessible(): @@ -64,7 +64,10 @@ def format_datetime(self, request, obj, fieldname, *args, **kwargs): def view_on_site(self, request, obj, fieldname, *args, **kwargs): available = obj.is_available - endpoint = kwargs.pop('endpoint', 'detail' if available else 'preview') + endpoint = kwargs.pop( + 'endpoint', + 'quokka.core.detail' if available else 'quokka.core.preview' + ) return html.a( href=obj.get_absolute_url(endpoint), target='_blank', @@ -104,7 +107,7 @@ def format_status(self, request, obj, fieldname, *args, **kwargs): def get_url(self, request, obj, fieldname, *args, **kwargs): column_formatters_args = getattr(self, 'column_formatters_args', {}) _args = column_formatters_args.get('get_url', {}).get(fieldname, {}) - attribute = _args.get('attribute', None) + attribute = _args.get('attribute') method = _args.get('method', 'get_absolute_url') text = getattr(obj, fieldname, '') if attribute: diff --git a/quokka/core/admin/views.py b/quokka/core/admin/views.py index 7c866423c..a598416c9 100644 --- a/quokka/core/admin/views.py +++ b/quokka/core/admin/views.py @@ -2,7 +2,7 @@ from flask import current_app, flash from flask.ext.admin.actions import action -from quokka.core.models import Content +from quokka.core.models.content import Content from quokka.utils.routing import expose from quokka.core.widgets import TextEditor, PrepopulatedText from .utils import _, _l diff --git a/quokka/core/app.py b/quokka/core/app.py index 5a632f373..428bd4df7 100644 --- a/quokka/core/app.py +++ b/quokka/core/app.py @@ -1,9 +1,15 @@ from flask import Flask, Blueprint +from flask.helpers import _endpoint_from_view_func from quokka.core.config import QuokkaConfig +from quokka.utils.aliases import dispatch_aliases class QuokkaApp(Flask): - """Implementes a customized config handler""" + """ + Implementes customizations on Flask + - Config handler + - Aliases dispatching before request + """ config_class = QuokkaConfig @@ -14,6 +20,22 @@ def make_config(self, instance_relative=False): root_path = self.instance_path return self.config_class(root_path, self.default_config) + def preprocess_request(self): + return dispatch_aliases() or super(QuokkaApp, + self).preprocess_request() + + def add_quokka_url_rule(self, rule, endpoint=None, + view_func=None, **options): + if endpoint is None: + endpoint = _endpoint_from_view_func(view_func) + if not endpoint.startswith('quokka.'): + endpoint = 'quokka.core.' + endpoint + self.add_url_rule(rule, endpoint, view_func, **options) + class QuokkaModule(Blueprint): - "for future overriding" + "Overwrite blueprint namespace to quokka.modules.name" + + def __init__(self, name, *args, **kwargs): + name = "quokka.modules." + name + super(QuokkaModule, self).__init__(name, *args, **kwargs) diff --git a/quokka/core/config.py b/quokka/core/config.py index 715d27e3c..a3a6f0571 100644 --- a/quokka/core/config.py +++ b/quokka/core/config.py @@ -1,8 +1,12 @@ import os +import logging +import quokka.core.models as m from flask.config import Config from quokka.utils import parse_conf_data from cached_property import cached_property_ttl, cached_property +logger = logging.getLogger() + class QuokkaConfig(Config): """A Config object for Flask that tries to ger vars from @@ -26,12 +30,14 @@ def all_setings_from_db(self): and Make it possible to use REDIS as a cache """ try: - from quokka.core.models import Config return { item.name: item.value - for item in Config.objects.get(group='settings').values + for item in m.config.Config.objects.get( + group='settings' + ).values } - except: + except Exception as e: + logger.warning('Error reading all settings from db: %s' % e) return {} def get_from_db(self, key, default=None): @@ -40,30 +46,6 @@ def get_from_db(self, key, default=None): def __getitem__(self, key): return self.get_from_db(key) or dict.__getitem__(self, key) - # def __iter__(self): - # return iter(self.store) - - # def __len__(self): - # return len(self.store) - - # def __repr__(self): - # return self.store.__repr__() - - # def __unicode__(self): - # return unicode(repr(self.store)) - - # def __call__(self, *args, **kwargs): - # return self.store.get(*args, **kwargs) - - # def __contains__(self, item): - # return item in self.store - - # def keys(self): - # return self.store.keys() - - # def values(self): - # return self.store.values() - def get(self, key, default=None): return self.get_from_db(key) or self.store.get(key) or default diff --git a/quokka/core/fields.py b/quokka/core/fields.py index 8055dc3bf..d57c54fd8 100644 --- a/quokka/core/fields.py +++ b/quokka/core/fields.py @@ -9,7 +9,8 @@ class MultipleObjectsReturned(Exception): def match_all(i, kwargs): - return all([getattr(i, k) == v for k, v in kwargs.items()]) + # use generator expression? + return all(getattr(i, k) == v for k, v in kwargs.items()) def getinstance(_instance): @@ -120,6 +121,10 @@ def __init__(self, *args, **kwargs): class ListField(fields.ListField): + + validators = [] # should be removed when flask.mongoengine updates + filters = [] # should be removed "" + def __get__(self, *args, **kwargs): value = super(ListField, self).__get__(*args, **kwargs) inject(value) diff --git a/quokka/core/middleware.py b/quokka/core/middleware.py index 3f8e494d0..dab626275 100644 --- a/quokka/core/middleware.py +++ b/quokka/core/middleware.py @@ -40,7 +40,7 @@ def _get_from_querystring(self, environ): return None def _get_method_override(self, environ): - return environ.get(self.header_name, None) or \ + return environ.get(self.header_name) or \ self._get_from_querystring(environ) or '' def __call__(self, environ, start_response): diff --git a/quokka/core/models.py b/quokka/core/models.py deleted file mode 100644 index ceb657385..000000000 --- a/quokka/core/models.py +++ /dev/null @@ -1,638 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import logging -import datetime -import random -from flask import url_for, current_app, redirect, request -from flask.ext.mistune import markdown - -from quokka.core import TEXT_FORMATS -from quokka.core.db import db -from quokka.core.fields import MultipleObjectsReturned -from quokka.modules.accounts.models import User -from quokka.utils.text import slugify -from quokka.utils import get_current_user_for_models -from quokka.utils.shorturl import ShorterURL -from quokka.utils.settings import get_setting_value, get_site_url -from .base_models.custom_values import HasCustomValue -from .admin.utils import _l - -logger = logging.getLogger() - - -############################################################### -# Commom extendable base classes -############################################################### - -class ContentFormat(object): - content_format = db.StringField( - choices=TEXT_FORMATS, - default=get_setting_value('DEFAULT_TEXT_FORMAT', 'html') - ) - - -class Dated(object): - available_at = db.DateTimeField(default=datetime.datetime.now) - available_until = db.DateTimeField(required=False) - created_at = db.DateTimeField(default=datetime.datetime.now) - updated_at = db.DateTimeField(default=datetime.datetime.now) - - -class Owned(object): - created_by = db.ReferenceField(User) - last_updated_by = db.ReferenceField(User) - authors = db.ListField(db.ReferenceField(User)) - - def get_authors(self, sortedf=None): - return set(self.authors + [self.created_by]) - - @property - def has_multiple_authors(self): - return len(self.get_authors()) > 1 - - -class Publishable(Dated, Owned): - published = db.BooleanField(default=False) - - @property - def is_available(self): - now = datetime.datetime.now() - return ( - self.published and - self.available_at <= now and - (self.available_until is None or - self.available_until >= now) - ) - - def save(self, *args, **kwargs): - self.updated_at = datetime.datetime.now() - - user = get_current_user_for_models() - - if not self.id and not self.created_by: - self.created_by = user - self.last_updated_by = user - - super(Publishable, self).save(*args, **kwargs) - - -class Slugged(object): - slug = db.StringField(max_length=255, required=True) - - def validate_slug(self, title=None): - if self.slug: - self.slug = slugify(self.slug) - else: - self.slug = slugify(title or self.title) - - -class LongSlugged(Slugged): - long_slug = db.StringField(unique=True, required=True) - mpath = db.StringField() - - def _create_mpath_long_slug(self): - if isinstance(self, Channel): - if self.parent and self.parent != self: - self.long_slug = "/".join( - [self.parent.long_slug, self.slug] - ) - self.mpath = "".join( - [self.parent.mpath, self.slug, ','] - ) - else: - self.long_slug = self.slug - self.mpath = ",%s," % self.slug - elif isinstance(self, Content): - self.long_slug = "/".join( - [self.channel.long_slug, self.slug] - ) - self.mpath = "".join([self.channel.mpath, self.slug, ',']) - - def validate_long_slug(self): - self._create_mpath_long_slug() - - filters = dict(long_slug=self.long_slug) - if self.id: - filters['id__ne'] = self.id - - exist = self.__class__.objects(**filters) - if exist.count(): - if current_app.config.get('SMART_SLUG_ENABLED', False): - self.slug = "{0}-{1}".format(self.slug, random.getrandbits(32)) - self._create_mpath_long_slug() - else: - raise db.ValidationError( - _l("%(slug)s slug already exists", - slug=self.long_slug) - ) - - -class Tagged(object): - tags = db.ListField(db.StringField(max_length=50)) - - -class Ordered(object): - order = db.IntField(required=True, default=1) - - -class ChannelConfigs(object): - content_filters = db.DictField(required=False, default=lambda: {}) - inherit_parent = db.BooleanField(default=True) - - -class TemplateType(HasCustomValue): - title = db.StringField(max_length=255, required=True) - identifier = db.StringField(max_length=255, required=True, unique=True) - template_suffix = db.StringField(max_length=255, required=True) - theme_name = db.StringField(max_length=255, required=False) - - def __unicode__(self): - return self.title - - -class ChannelType(TemplateType, ChannelConfigs, db.DynamicDocument): - """Define the channel template type and its filters""" - - -class ContentProxy(db.DynamicDocument): - content = db.GenericReferenceField(required=True, unique=True) - - def __unicode__(self): - return self.content.title - - -class Channel(Tagged, HasCustomValue, Publishable, LongSlugged, - ChannelConfigs, ContentFormat, db.DynamicDocument): - title = db.StringField(max_length=255, required=True) - description = db.StringField() - show_in_menu = db.BooleanField(default=False) - is_homepage = db.BooleanField(default=False) - roles = db.ListField( - db.ReferenceField('Role', reverse_delete_rule=db.PULL)) - include_in_rss = db.BooleanField(default=True) - indexable = db.BooleanField(default=True) - canonical_url = db.StringField() - order = db.IntField(default=0) - - parent = db.ReferenceField('self', required=False, default=None, - reverse_delete_rule=db.DENY) - - per_page = db.IntField(default=0) - aliases = db.ListField(db.StringField(), default=[]) - channel_type = db.ReferenceField(ChannelType, required=False, - reverse_delete_rule=db.NULLIFY) - - redirect_url = db.StringField(max_length=255) - render_content = db.ReferenceField(ContentProxy, - required=False, - reverse_delete_rule=db.NULLIFY) - sort_by = db.ListField(db.StringField(), default=[]) - - meta = { - 'ordering': ['order', 'title'], - } - - def get_text(self): - if self.content_format == "markdown": - return markdown(self.description) - else: - return self.description - - def get_content_filters(self): - filters = {} - if self.channel_type and self.channel_type.content_filters: - filters.update(self.channel_type.content_filters) - if self.content_filters: - filters.update(self.content_filters) - return filters - - def get_ancestors_count(self): - """ - return how many ancestors this node has based on slugs - """ - return len(self.get_ancestors_slugs()) - - def get_ancestors_slugs(self): - """return ancestors slugs including self as 1st item - >>> channel = Channel(long_slug='articles/technology/programming') - >>> channel.get_ancestors_slugs() - ['articles/technology/programming', - 'articles/technology', - 'articles'] - """ - - channel_list = [] - channel_slugs = self.long_slug.split('/') - while channel_slugs: - channel_list.append("/".join(channel_slugs)) - channel_slugs.pop() - return channel_list - - def get_ancestors(self, **kwargs): - """return all ancestors includind self as 1st item""" - channel_list = self.get_ancestors_slugs() - ancestors = self.__class__.objects( - long_slug__in=channel_list, - **kwargs - ).order_by('-long_slug') - return ancestors - - def get_children(self, **kwargs): - """return direct children 1 level depth""" - return self.__class__.objects( - parent=self, **kwargs - ).order_by('long_slug') - - def get_descendants(self, **kwargs): - """return all descendants including self as 1st item""" - return self.__class__.objects( - __raw__={'mpath': {'$regex': '^{0}'.format(self.mpath)}} - ).order_by('long_slug') - - def get_themes(self): - return list({ - c.channel_type.theme_name - for c in self.get_ancestors(channel_type__ne=None) - if c.channel_type and c.channel_type.theme_name - }) - - @classmethod - def get_homepage(cls, attr=None): - try: - homepage = cls.objects.get(is_homepage=True) - except Exception as e: - logger.info("There is no homepage: %s", e.message) - return None - else: - if not attr: - return homepage - else: - return getattr(homepage, attr, homepage) - - def __unicode__(self): - return self.long_slug - - def get_absolute_url(self, *args, **kwargs): - if self.is_homepage: - return "/" - return "/{0}/".format(self.long_slug) - - def get_canonical_url(self, *args, **kwargs): - """ - This method should be reviewed - Canonical URL is the preferred URL for a content - when the content can be served by multiple URLS - In the case of channels it will never happen - until we implement the channel alias feature - """ - if self.is_homepage: - return "/" - return self.get_absolute_url() - - def get_http_url(self): - site_url = Config.get('site', 'site_domain', request.url_root) - return u"{}{}".format(site_url, self.get_absolute_url()) - - def clean(self): - homepage = Channel.objects(is_homepage=True) - if self.is_homepage and homepage and self not in homepage: - raise db.ValidationError(_l("Home page already exists")) - super(Channel, self).clean() - - def validate_render_content(self): - if self.render_content and \ - not isinstance(self.render_content, ContentProxy): - self.render_content, created = ContentProxy.objects.get_or_create( - content=self.render_content) - else: - self.render_content = None - - def heritage(self): - """populate inheritance from parent channels""" - parent = self.parent - if not parent or not self.inherit_parent: - return - - self.content_filters = self.content_filters or parent.content_filters - self.include_in_rss = self.include_in_rss or parent.include_in_rss - self.show_in_menu = self.show_in_menu or parent.show_in_menu - self.indexable = self.indexable or parent.indexable - self.channel_type = self.channel_type or parent.channel_type - - def update_descendants_and_contents(self): - """ - Need to Detect if self.long_slug and self.mpath has changed. - if so, update every descendant using get_descendatns method - to query. - Also update long_slug and mpath for every Content in this channel - This needs to be done by default in araw immediate way, but if - current_app.config.get('ASYNC_SAVE_MODE') is True it will delegate - all those tasks to celery.""" - - def save(self, *args, **kwargs): - self.validate_render_content() - self.validate_slug() - self.validate_long_slug() - self.heritage() - self.update_descendants_and_contents() - if not self.channel_type: - self.channel_type = ChannelType.objects.first() - super(Channel, self).save(*args, **kwargs) - - -class Channeling(object): - channel = db.ReferenceField(Channel, required=True, - reverse_delete_rule=db.DENY) - related_channels = db.ListField( - db.ReferenceField('Channel', reverse_delete_rule=db.PULL) - ) - related_mpath = db.ListField(db.StringField()) - show_on_channel = db.BooleanField(default=True) - channel_roles = db.ListField(db.StringField()) - - def populate_related_mpath(self): - self.related_mpath = [rel.mpath for rel in self.related_channels] - - def populate_channel_roles(self): - self.channel_roles = [role.name for role in self.channel.roles] - - -class ChannelingNotRequired(Channeling): - channel = db.ReferenceField(Channel, required=False, - reverse_delete_rule=db.NULLIFY) - - -class Config(HasCustomValue, ContentFormat, Publishable, db.DynamicDocument): - group = db.StringField(max_length=255) - description = db.StringField() - - @classmethod - def get(cls, group, name=None, default=None): - - try: - instance = cls.objects.get(group=group) - except: - return None - - if not name: - ret = instance.values - if group == 'settings': - ret = {} - ret.update(current_app.config) - ret.update({item.name: item.value for item in instance.values}) - else: - try: - ret = instance.values.get(name=name).value - except (MultipleObjectsReturned, AttributeError): - ret = None - - if not ret and group == 'settings' and name is not None: - # get direct from store to avoid infinite loop - ret = current_app.config.store.get(name) - - return ret or default - - def __unicode__(self): - return self.group - - -class Quokka(Dated, Slugged, db.DynamicDocument): - """ Hidden collection """ - - -class ContentTemplateType(TemplateType, db.Document): - """Define the content template type and its theme""" - - -class SubContentPurpose(db.Document): - title = db.StringField(max_length=255, required=True) - identifier = db.StringField(max_length=255, required=True, unique=True) - module = db.StringField() - - def save(self, *args, **kwargs): - self.identifier = slugify(self.identifier or self.title) - super(SubContentPurpose, self).save(*args, **kwargs) - - def __unicode__(self): - return self.title - - -class SubContent(Publishable, Ordered, db.EmbeddedDocument): - """Content can have inner contents - Its useful for any kind of relation with Content childs - Images, ImageGalleries, RelatedContent, Attachments, Media - """ - - content = db.ReferenceField('Content', required=True) - caption = db.StringField() - purpose = db.ReferenceField(SubContentPurpose, required=True) - identifier = db.StringField() - - @property - def thumb(self): - try: - return url_for('media', filename=self.content.thumb) - # return self.content.thumb - except Exception as e: - logger.warning(str(e)) - return self.content.get_main_image_url(thumb=True) - - meta = { - 'ordering': ['order'], - 'indexes': ['order'] - } - - def clean(self): - self.identifier = self.purpose.identifier - - def __unicode__(self): - return self.content and self.content.title or self.caption - - -class License(db.EmbeddedDocument): - LICENSES = (('custom', 'custom'), - ('creative_commons_by_nc_nd', 'creative_commons_by_nc_nd')) - title = db.StringField(max_length=255) - link = db.StringField(max_length=255) - identifier = db.StringField(max_length=255, choices=LICENSES) - - -class ShortenedURL(db.EmbeddedDocument): - original = db.StringField(max_length=255) - short = db.StringField(max_length=255) - - def __str__(self): - return self.short - - -############################################################### -# Base Content for every new content to extend. inheritance=True -############################################################### - - -class Content(HasCustomValue, Publishable, LongSlugged, - Channeling, Tagged, ContentFormat, db.DynamicDocument): - title = db.StringField(max_length=255, required=True) - summary = db.StringField(required=False) - template_type = db.ReferenceField(ContentTemplateType, - required=False, - reverse_delete_rule=db.NULLIFY) - contents = db.ListField(db.EmbeddedDocumentField(SubContent)) - model = db.StringField() - comments_enabled = db.BooleanField(default=True) - license = db.EmbeddedDocumentField(License) - shortened_url = db.EmbeddedDocumentField(ShortenedURL) - - meta = { - 'allow_inheritance': True, - 'indexes': ['-created_at', 'slug'], - 'ordering': ['-created_at'], - } - - @classmethod - def available_objects(cls, **filters): - now = datetime.datetime.now() - default_filters = { - "published": True, - 'available_at__lte': now, - } - default_filters.update(filters) - return cls.objects(**default_filters) - - def get_main_image_url(self, thumb=False, - default=None, identifier='mainimage'): - """ - """ - if not isinstance(identifier, (list, tuple)): - identifier = [identifier] - - for item in identifier: - try: - if not thumb: - path = self.contents.get(identifier=item).content.path - else: - path = self.contents.get(identifier=item).content.thumb - return url_for('media', filename=path) - except Exception as e: - logger.warning('get_main_image_url:' + str(e)) - - return default - - def get_uid(self): - return str(self.id) - - def get_themes(self): - themes = self.channel.get_themes() - theme = self.template_type and self.template_type.theme_name - if theme: - themes.insert(0, theme) - return list(set(themes)) - - def get_http_url(self): - site_url = get_site_url() - absolute_url = self.get_absolute_url() - absolute_url = absolute_url[1:] - return u"{}{}".format(site_url, absolute_url) - - def get_absolute_url(self, endpoint='detail'): - if self.channel.is_homepage: - long_slug = self.slug - else: - long_slug = self.long_slug - - try: - return url_for(self.URL_NAMESPACE, long_slug=long_slug) - except: - return url_for(endpoint, long_slug=long_slug) - - def get_canonical_url(self, *args, **kwargs): - return self.get_absolute_url() - - def get_recommendations(self, limit=3, ordering='-created_at', *a, **k): - now = datetime.datetime.now() - filters = { - 'published': True, - 'available_at__lte': now, - "id__ne": self.id - } - contents = Content.objects(**filters).filter(tags__in=self.tags or []) - - return contents.order_by(ordering)[:limit] - - def get_summary(self): - if self.summary: - return self.summary - return self.get_text() - - def get_text(self): - if hasattr(self, 'body'): - text = self.body - elif hasattr(self, 'description'): - text = self.description - else: - text = self.summary or "" - - if self.content_format == "markdown": - return markdown(text) - else: - return text - - def __unicode__(self): - return self.title - - @property - def short_url(self): - return self.shortened_url.short if self.shortened_url else '' - - @property - def model_name(self): - return self.__class__.__name__.lower() - - @property - def module_name(self): - module = self.__module__ - module_name = module.replace('quokka.modules.', '').split('.')[0] - return module_name - - def heritage(self): - self.model = "{0}.{1}".format(self.module_name, self.model_name) - - def save(self, *args, **kwargs): - # all those functions should be in a dynamic pipeline - self.validate_slug() - self.validate_long_slug() - self.heritage() - self.populate_related_mpath() - self.populate_channel_roles() - self.populate_shorter_url() - super(Content, self).save(*args, **kwargs) - - def pre_render(self, render_function, *args, **kwargs): - return render_function(*args, **kwargs) - - def populate_shorter_url(self): - if not self.published or not get_setting_value('SHORTENER_ENABLED'): - return - - url = self.get_http_url() - if not self.shortened_url or url != self.shortened_url.original: - shortener = ShorterURL() - self.shortened_url = ShortenedURL(original=url, - short=shortener.short(url)) - - -class Link(Content): - link = db.StringField(required=True) - force_redirect = db.BooleanField(default=True) - increment_visits = db.BooleanField(default=True) - visits = db.IntField(default=0) - show_on_channel = db.BooleanField(default=False) - - def pre_render(self, render_function, *args, **kwargs): - if self.increment_visits: - self.visits = self.visits + 1 - self.save() - if self.force_redirect: - return redirect(self.link) - return super(Link, self).pre_render(render_function, *args, **kwargs) diff --git a/quokka/core/base_models/__init__.py b/quokka/core/models/__init__.py similarity index 100% rename from quokka/core/base_models/__init__.py rename to quokka/core/models/__init__.py diff --git a/quokka/core/models/channel.py b/quokka/core/models/channel.py new file mode 100644 index 000000000..2cca21e32 --- /dev/null +++ b/quokka/core/models/channel.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +from flask.ext.mistune import markdown + +from quokka.core.db import db +from quokka.core.models.custom_values import HasCustomValue +from quokka.core.models.signature import ( + Tagged, Publishable, LongSlugged, ContentFormat, TemplateType +) +from quokka.core.admin.utils import _l +from quokka.utils.settings import get_site_url +logger = logging.getLogger() + + +class ChannelConfigs(object): + content_filters = db.DictField(required=False, default=lambda: {}) + inherit_parent = db.BooleanField(default=True) + + +class ChannelType(TemplateType, ChannelConfigs, db.DynamicDocument): + """Define the channel template type and its filters""" + + +class ContentProxy(db.DynamicDocument): + content = db.GenericReferenceField(required=True, unique=True) + + def __unicode__(self): + return self.content.title + + +class Channel(Tagged, HasCustomValue, Publishable, LongSlugged, + ChannelConfigs, ContentFormat, db.DynamicDocument): + title = db.StringField(max_length=255, required=True) + description = db.StringField() + show_in_menu = db.BooleanField(default=False) + is_homepage = db.BooleanField(default=False) + roles = db.ListField( + db.ReferenceField('Role', reverse_delete_rule=db.PULL)) + include_in_rss = db.BooleanField(default=True) + indexable = db.BooleanField(default=True) + canonical_url = db.StringField() + order = db.IntField(default=0) + + parent = db.ReferenceField('self', required=False, default=None, + reverse_delete_rule=db.DENY) + + per_page = db.IntField(default=0) + aliases = db.ListField(db.StringField(), default=[]) + channel_type = db.ReferenceField(ChannelType, required=False, + reverse_delete_rule=db.NULLIFY) + + redirect_url = db.StringField(max_length=255) + render_content = db.ReferenceField(ContentProxy, + required=False, + reverse_delete_rule=db.NULLIFY) + sort_by = db.ListField(db.StringField(), default=[]) + + meta = { + 'ordering': ['order', 'title'], + } + + def get_text(self): + if self.content_format == "markdown": + return markdown(self.description) + else: + return self.description + + def get_content_filters(self): + filters = {} + if self.channel_type and self.channel_type.content_filters: + filters.update(self.channel_type.content_filters) + if self.content_filters: + filters.update(self.content_filters) + return filters + + def get_ancestors_count(self): + """ + return how many ancestors this node has based on slugs + """ + return len(self.get_ancestors_slugs()) + + def get_ancestors_slugs(self): + """return ancestors slugs including self as 1st item + >>> channel = Channel(long_slug='articles/technology/programming') + >>> channel.get_ancestors_slugs() + ['articles/technology/programming', + 'articles/technology', + 'articles'] + """ + + channel_list = [] + channel_slugs = self.long_slug.split('/') + while channel_slugs: + channel_list.append("/".join(channel_slugs)) + channel_slugs.pop() + return channel_list + + def get_ancestors(self, **kwargs): + """return all ancestors includind self as 1st item""" + channel_list = self.get_ancestors_slugs() + ancestors = self.__class__.objects( + long_slug__in=channel_list, + **kwargs + ).order_by('-long_slug') + return ancestors + + def get_children(self, **kwargs): + """return direct children 1 level depth""" + return self.__class__.objects( + parent=self, **kwargs + ).order_by('long_slug') + + def get_descendants(self, **kwargs): + """return all descendants including self as 1st item""" + return self.__class__.objects( + __raw__={'mpath': {'$regex': '^{0}'.format(self.mpath)}} + ).order_by('long_slug') + + def get_themes(self): + return list({ + c.channel_type.theme_name + for c in self.get_ancestors(channel_type__ne=None) + if c.channel_type and c.channel_type.theme_name + }) + + @classmethod + def get_homepage(cls, attr=None): + try: + homepage = cls.objects.get(is_homepage=True) + except Exception as e: + logger.info("There is no homepage: %s", e.message) + return None + else: + if not attr: + return homepage + else: + return getattr(homepage, attr, homepage) + + def __unicode__(self): + return self.long_slug + + def get_absolute_url(self, *args, **kwargs): + if self.is_homepage: + return "/" + return "/{0}/".format(self.long_slug) + + def get_canonical_url(self, *args, **kwargs): + """ + This method should be reviewed + Canonical URL is the preferred URL for a content + when the content can be served by multiple URLS + In the case of channels it will never happen + until we implement the channel alias feature + """ + if self.is_homepage: + return "/" + return self.get_absolute_url() + + def get_http_url(self): + site_url = get_site_url() + absolute_url = self.get_absolute_url() + absolute_url = absolute_url[1:] + return u"{0}{1}".format(site_url, absolute_url) + + def clean(self): + homepage = Channel.objects(is_homepage=True) + if self.is_homepage and homepage and self not in homepage: + raise db.ValidationError(_l("Home page already exists")) + super(Channel, self).clean() + + def validate_render_content(self): + if self.render_content and \ + not isinstance(self.render_content, ContentProxy): + self.render_content, created = ContentProxy.objects.get_or_create( + content=self.render_content) + else: + self.render_content = None + + def heritage(self): + """populate inheritance from parent channels""" + parent = self.parent + if not parent or not self.inherit_parent: + return + + self.content_filters = self.content_filters or parent.content_filters + self.include_in_rss = self.include_in_rss or parent.include_in_rss + self.show_in_menu = self.show_in_menu or parent.show_in_menu + self.indexable = self.indexable or parent.indexable + self.channel_type = self.channel_type or parent.channel_type + + def update_descendants_and_contents(self): + """ + Need to Detect if self.long_slug and self.mpath has changed. + if so, update every descendant using get_descendatns method + to query. + Also update long_slug and mpath for every Content in this channel + This needs to be done by default in araw immediate way, but if + current_app.config.get('ASYNC_SAVE_MODE') is True it will delegate + all those tasks to celery.""" + + def save(self, *args, **kwargs): + self.validate_render_content() + self.validate_slug() + self.validate_long_slug() + self.heritage() + self.update_descendants_and_contents() + if not self.channel_type: + self.channel_type = ChannelType.objects.first() + super(Channel, self).save(*args, **kwargs) + + +class Channeling(object): + channel = db.ReferenceField(Channel, required=True, + reverse_delete_rule=db.DENY) + related_channels = db.ListField( + db.ReferenceField('Channel', reverse_delete_rule=db.PULL) + ) + related_mpath = db.ListField(db.StringField()) + show_on_channel = db.BooleanField(default=True) + channel_roles = db.ListField(db.StringField()) + + def populate_related_mpath(self): + self.related_mpath = [rel.mpath for rel in self.related_channels] + + def populate_channel_roles(self): + self.channel_roles = [role.name for role in self.channel.roles] + + +class ChannelingNotRequired(Channeling): + channel = db.ReferenceField(Channel, required=False, + reverse_delete_rule=db.NULLIFY) diff --git a/quokka/core/models/config.py b/quokka/core/models/config.py new file mode 100644 index 000000000..176387c7e --- /dev/null +++ b/quokka/core/models/config.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +from flask import current_app +from quokka.core.db import db +from quokka.core.fields import MultipleObjectsReturned +from quokka.core.models.custom_values import HasCustomValue +from quokka.core.models.signature import ( + Publishable, ContentFormat, Dated, Slugged +) + +logger = logging.getLogger() + + +class Config(HasCustomValue, ContentFormat, Publishable, db.DynamicDocument): + group = db.StringField(max_length=255) + description = db.StringField() + + @classmethod + def get(cls, group, name=None, default=None): + + try: + instance = cls.objects.get(group=group) + except: + return None + + if not name: + ret = instance.values + if group == 'settings': + ret = {} + ret.update(current_app.config) + ret.update({item.name: item.value for item in instance.values}) + else: + try: + ret = instance.values.get(name=name).value + except (MultipleObjectsReturned, AttributeError): + ret = None + + if not ret and group == 'settings' and name is not None: + # get direct from store to avoid infinite loop + ret = current_app.config.store.get(name) + + return ret or default + + def __unicode__(self): + return self.group + + +class Quokka(Dated, Slugged, db.DynamicDocument): + """ Hidden collection for installation control""" diff --git a/quokka/core/models/content.py b/quokka/core/models/content.py new file mode 100644 index 000000000..d34353fb7 --- /dev/null +++ b/quokka/core/models/content.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +import datetime +from flask import url_for, redirect +from flask.ext.mistune import markdown + +from quokka.core.db import db +from quokka.utils.shorturl import ShorterURL +from quokka.utils.settings import get_setting_value, get_site_url +from quokka.core.models.channel import Channeling +from quokka.core.models.custom_values import HasCustomValue +from quokka.core.models.subcontent import SubContent +from quokka.core.models.signature import ( + Tagged, Publishable, LongSlugged, ContentFormat, TemplateType +) + +logger = logging.getLogger() + + +class ContentTemplateType(TemplateType, db.Document): + """Define the content template type and its theme""" + + +class License(db.EmbeddedDocument): + LICENSES = (('custom', 'custom'), + ('creative_commons_by_nc_nd', 'creative_commons_by_nc_nd')) + title = db.StringField(max_length=255) + link = db.StringField(max_length=255) + identifier = db.StringField(max_length=255, choices=LICENSES) + + +class ShortenedURL(db.EmbeddedDocument): + original = db.StringField(max_length=255) + short = db.StringField(max_length=255) + + def __str__(self): + return self.short + + +############################################################### +# Base Content for every new content to extend. inheritance=True +############################################################### + + +class Content(HasCustomValue, Publishable, LongSlugged, + Channeling, Tagged, ContentFormat, db.DynamicDocument): + title = db.StringField(max_length=255, required=True) + summary = db.StringField(required=False) + template_type = db.ReferenceField(ContentTemplateType, + required=False, + reverse_delete_rule=db.NULLIFY) + contents = db.ListField(db.EmbeddedDocumentField(SubContent)) + model = db.StringField() + comments_enabled = db.BooleanField(default=True) + license = db.EmbeddedDocumentField(License) + shortened_url = db.EmbeddedDocumentField(ShortenedURL) + + meta = { + 'allow_inheritance': True, + 'indexes': ['-created_at', 'slug'], + 'ordering': ['-created_at'], + } + + @classmethod + def available_objects(cls, **filters): + now = datetime.datetime.now() + default_filters = { + "published": True, + 'available_at__lte': now, + } + default_filters.update(filters) + return cls.objects(**default_filters) + + def get_main_image_url(self, thumb=False, + default=None, identifier='mainimage'): + """method returns the path (url) of the main image + """ + if not isinstance(identifier, (list, tuple)): + identifier = [identifier] + + for item in identifier: + try: + if not thumb: + path = self.contents.get(identifier=item).content.path + else: + path = self.contents.get(identifier=item).content.thumb + return url_for('quokka.core.media', filename=path) + except Exception as e: + logger.warning('get_main_image_url:' + str(e)) + + return default + + def get_main_image_http(self, thumb=False, + default=None, identifier='mainimage'): + """method returns the path of the main image with http + """ + site_url = get_site_url() + image_url = self.get_main_image_url( + thumb=thumb, default=default, identifier=identifier) + return u"{}{}".format(site_url, image_url) + + def get_uid(self): + return str(self.id) + + def get_themes(self): + themes = self.channel.get_themes() + theme = self.template_type and self.template_type.theme_name + if theme: + themes.insert(0, theme) + return list(set(themes)) + + def get_http_url(self): + site_url = get_site_url() + absolute_url = self.get_absolute_url() + absolute_url = absolute_url[1:] + return u"{0}{1}".format(site_url, absolute_url) + + def get_absolute_url(self, endpoint='quokka.core.detail'): + if self.channel.is_homepage: + long_slug = self.slug + else: + long_slug = self.long_slug + + try: + return url_for(self.URL_NAMESPACE, long_slug=long_slug) + except: + return url_for(endpoint, long_slug=long_slug) + + def get_canonical_url(self, *args, **kwargs): + return self.get_absolute_url() + + def get_recommendations(self, limit=3, ordering='-created_at', *a, **k): + now = datetime.datetime.now() + filters = { + 'published': True, + 'available_at__lte': now, + "id__ne": self.id + } + contents = Content.objects(**filters).filter(tags__in=self.tags or []) + + return contents.order_by(ordering)[:limit] + + def get_summary(self): + if self.summary: + return self.summary + return self.get_text() + + def get_text(self): + if hasattr(self, 'body'): + text = self.body + elif hasattr(self, 'description'): + text = self.description + else: + text = self.summary or "" + + if self.content_format == "markdown": + return markdown(text) + else: + return text + + def __unicode__(self): + return self.title + + @property + def short_url(self): + return self.shortened_url.short if self.shortened_url else '' + + @property + def model_name(self): + return self.__class__.__name__.lower() + + @property + def module_name(self): + module = self.__module__ + module_name = module.replace('quokka.modules.', '').split('.')[0] + return module_name + + def heritage(self): + self.model = "{0}.{1}".format(self.module_name, self.model_name) + + def save(self, *args, **kwargs): + # all those functions should be in a dynamic pipeline + self.validate_slug() + self.validate_long_slug() + self.heritage() + self.populate_related_mpath() + self.populate_channel_roles() + self.populate_shorter_url() + super(Content, self).save(*args, **kwargs) + + def pre_render(self, render_function, *args, **kwargs): + return render_function(*args, **kwargs) + + def populate_shorter_url(self): + if not self.published or not get_setting_value('SHORTENER_ENABLED'): + return + + url = self.get_http_url() + if not self.shortened_url or url != self.shortened_url.original: + shortener = ShorterURL() + self.shortened_url = ShortenedURL(original=url, + short=shortener.short(url)) + + +class Link(Content): + link = db.StringField(required=True) + force_redirect = db.BooleanField(default=True) + increment_visits = db.BooleanField(default=True) + visits = db.IntField(default=0) + show_on_channel = db.BooleanField(default=False) + + def pre_render(self, render_function, *args, **kwargs): + if self.increment_visits: + self.visits = self.visits + 1 + self.save() + if self.force_redirect: + return redirect(self.link) + return super(Link, self).pre_render(render_function, *args, **kwargs) diff --git a/quokka/core/base_models/custom_values.py b/quokka/core/models/custom_values.py similarity index 85% rename from quokka/core/base_models/custom_values.py rename to quokka/core/models/custom_values.py index af932a954..7739417ca 100644 --- a/quokka/core/base_models/custom_values.py +++ b/quokka/core/models/custom_values.py @@ -76,6 +76,19 @@ def get_value(self, name, default=None): except: return default + def add_value(self, name, value, formatter='text'): + """ + Another implementation + data = {"name": name, "rawvalue": value, "formatter": formatter} + self.values.update(data, name=name) or self.values.create(**data) + """ + custom_value = CustomValue( + name=name, + value=value, + formatter=formatter + ) + self.values.append(custom_value) + def clean(self): current_names = [value.name for value in self.values] for name in current_names: diff --git a/quokka/core/models/signature.py b/quokka/core/models/signature.py new file mode 100644 index 000000000..b07d5f4f5 --- /dev/null +++ b/quokka/core/models/signature.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import datetime +import random +from flask import current_app +from quokka.core import TEXT_FORMATS +from quokka.core.db import db +from quokka.modules.accounts.models import User +from quokka.utils.text import slugify +from quokka.utils import get_current_user_for_models +from quokka.utils.settings import get_setting_value +from quokka.core.admin.utils import _l +from quokka.core.models.custom_values import HasCustomValue + +############################################################### +# Commom extendable base classes +############################################################### + + +class ContentFormat(object): + content_format = db.StringField( + choices=TEXT_FORMATS, + default=get_setting_value('DEFAULT_TEXT_FORMAT', 'html') + ) + + +class Dated(object): + available_at = db.DateTimeField(default=datetime.datetime.now) + available_until = db.DateTimeField(required=False) + created_at = db.DateTimeField(default=datetime.datetime.now) + updated_at = db.DateTimeField(default=datetime.datetime.now) + + +class Owned(object): + created_by = db.ReferenceField(User) + last_updated_by = db.ReferenceField(User) + authors = db.ListField(db.ReferenceField(User)) + + def get_authors(self): + return set(self.authors + [self.created_by]) + + @property + def has_multiple_authors(self): + return len(self.get_authors()) > 1 + + +class Publishable(Dated, Owned): + published = db.BooleanField(default=False) + + @property + def is_available(self): + now = datetime.datetime.now() + return ( + self.published and + self.available_at <= now and + (self.available_until is None or + self.available_until >= now) + ) + + def save(self, *args, **kwargs): + self.updated_at = datetime.datetime.now() + + user = get_current_user_for_models() + + if not self.id and not self.created_by: + self.created_by = user + self.last_updated_by = user + + super(Publishable, self).save(*args, **kwargs) + + +class Slugged(object): + slug = db.StringField(max_length=255, required=True) + + def validate_slug(self, title=None): + if self.slug: + self.slug = slugify(self.slug) + else: + self.slug = slugify(title or self.title) + + +class LongSlugged(Slugged): + long_slug = db.StringField(unique=True, required=True) + mpath = db.StringField() + + def _create_mpath_long_slug(self): + if hasattr(self, 'is_homepage'): # is channel + if self.parent and self.parent != self: + self.long_slug = "/".join( + [self.parent.long_slug, self.slug] + ) + self.mpath = "".join( + [self.parent.mpath, self.slug, ','] + ) + else: + self.long_slug = self.slug + self.mpath = ",%s," % self.slug + else: # is Content + self.long_slug = "/".join( + [self.channel.long_slug, self.slug] + ) + self.mpath = "".join([self.channel.mpath, self.slug, ',']) + + def validate_long_slug(self): + self._create_mpath_long_slug() + + filters = dict(long_slug=self.long_slug) + if self.id: + filters['id__ne'] = self.id + + exist = self.__class__.objects(**filters) + if exist.count(): + if current_app.config.get('SMART_SLUG_ENABLED', False): + self.slug = "{0}-{1}".format(self.slug, random.getrandbits(32)) + self._create_mpath_long_slug() + else: + raise db.ValidationError( + _l("%(slug)s slug already exists", + slug=self.long_slug) + ) + + +class Tagged(object): + tags = db.ListField(db.StringField(max_length=50)) + + +class Ordered(object): + order = db.IntField(required=True, default=1) + + +class TemplateType(HasCustomValue): + title = db.StringField(max_length=255, required=True) + identifier = db.StringField(max_length=255, required=True, unique=True) + template_suffix = db.StringField(max_length=255, required=True) + theme_name = db.StringField(max_length=255, required=False) + + def __unicode__(self): + return self.title diff --git a/quokka/core/models/subcontent.py b/quokka/core/models/subcontent.py new file mode 100644 index 000000000..98cc21aba --- /dev/null +++ b/quokka/core/models/subcontent.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +from flask import url_for +from quokka.core.db import db +from quokka.utils.text import slugify +from quokka.core.models.signature import Publishable, Ordered + +logger = logging.getLogger() + + +class SubContentPurpose(db.Document): + title = db.StringField(max_length=255, required=True) + identifier = db.StringField(max_length=255, required=True, unique=True) + module = db.StringField() + + def save(self, *args, **kwargs): + self.identifier = slugify(self.identifier or self.title) + super(SubContentPurpose, self).save(*args, **kwargs) + + def __unicode__(self): + return self.title + + +class SubContent(Publishable, Ordered, db.EmbeddedDocument): + """Content can have inner contents + Its useful for any kind of relation with Content childs + Images, ImageGalleries, RelatedContent, Attachments, Media + """ + + content = db.ReferenceField('Content', required=True) + caption = db.StringField() + purpose = db.ReferenceField(SubContentPurpose, required=True) + identifier = db.StringField() + + @property + def thumb(self): + try: + return url_for('quokka.core.media', filename=self.content.thumb) + # return self.content.thumb + except Exception as e: + logger.warning(str(e)) + return self.content.get_main_image_url(thumb=True) + + meta = { + 'ordering': ['order'], + 'indexes': ['order'] + } + + def clean(self): + self.identifier = self.purpose.identifier + + def __unicode__(self): + return self.content and self.content.title or self.caption diff --git a/quokka/core/tests/test_models.py b/quokka/core/tests/test_models.py index 986adf25e..0c6d12899 100644 --- a/quokka/core/tests/test_models.py +++ b/quokka/core/tests/test_models.py @@ -2,9 +2,9 @@ import sys from . import BaseTestCase - -from ..models import Channel, Config -from ..base_models.custom_values import CustomValue +from quokka.core.models.channel import Channel +from quokka.core.models.config import Config +from quokka.core.models.custom_values import CustomValue if sys.version_info.major == 3: unicode = lambda x: u'{}'.format(x) # flake8: noqa # noqa diff --git a/quokka/core/views.py b/quokka/core/views.py index 7c11d56e4..364a8a82e 100644 --- a/quokka/core/views.py +++ b/quokka/core/views.py @@ -1,26 +1,26 @@ # coding: utf-8 +import sys import logging -import collections import hashlib +import collections import PyRSS2Gen as pyrss -import sys from datetime import datetime, timedelta from flask import request, redirect, url_for, abort, current_app from flask.views import MethodView from quokka.utils.atom import AtomFeed -from quokka.core.models import Channel, Content, Config +from quokka.core.models.channel import Channel +from quokka.core.models.config import Config +from quokka.core.models.content import Content from quokka.core.templates import render_template from quokka.utils import is_accessible, get_current_user # python3 support if sys.version_info.major == 3: from urllib.parse import urljoin - # from io import StringIO else: from urlparse import urljoin - # import StringIO logger = logging.getLogger() @@ -75,7 +75,11 @@ def get(self, long_slug): published_channels = Channel.objects(published=True).values_list('id') if channel.redirect_url: - return redirect(channel.redirect_url) + url_protos = ('http://', 'mailto:', '/', 'ftp://') + if channel.redirect_url.startswith(url_protos): + return redirect(channel.redirect_url) + else: + return redirect(url_for(channel.redirect_url)) if channel.render_content: return ContentDetail().get( @@ -217,7 +221,8 @@ def get_template_names(self): return names - def get_filters(self): + @staticmethod + def get_filters(): now = datetime.now() filters = { 'published': True, @@ -225,7 +230,8 @@ def get_filters(self): } return filters - def check_if_is_accessible(self, content): + @staticmethod + def check_if_is_accessible(content): if not content.channel.is_available: return abort(404) @@ -241,7 +247,7 @@ def get_context(self, long_slug, render_content=False): len(long_slug.split('/')) < 3 and \ not render_content: slug = long_slug.split('/')[-1] - return redirect(url_for('detail', long_slug=slug)) + return redirect(url_for('quokka.core.detail', long_slug=slug)) filters = self.get_filters() @@ -281,10 +287,13 @@ def get(self, long_slug, render_content=False): class ContentDetailPreview(ContentDetail): - def get_filters(self): + + @staticmethod + def get_filters(): return {} - def check_if_is_accessible(self, content): + @staticmethod + def check_if_is_accessible(content): if not content.channel.published: return abort(404) @@ -355,7 +364,8 @@ def cdata(data): class BaseFeed(MethodView): - def make_external_url(self, url): + @staticmethod + def make_external_url(url): return urljoin(request.url_root, url) def make_atom(self, feed_name, contents): @@ -543,3 +553,38 @@ def get(self, tag): ) return self.make_rss(feed_name, contents) + + +class SiteMap(MethodView): + @staticmethod + def make_external_url(url): + return urljoin(request.url_root, url) + + def get_contents(self): + now = datetime.now() + filters = { + 'published': True, + 'available_at__lte': now, + } + contents = Content.objects().filter(**filters) + return contents + + def get_channels(self): + now = datetime.now() + filters = { + 'published': True, + 'available_at__lte': now, + } + channels = Channel.objects().filter(**filters) + return channels + + def get(self): + """ + Fixme: Should include extra paths, fixed paths + config based paths, static paths + """ + return render_template( + 'sitemap.xml', + contents=self.get_contents(), + channels=self.get_channels() + ) diff --git a/quokka/ext/__init__.py b/quokka/ext/__init__.py index e409d319a..4eb874ec7 100644 --- a/quokka/ext/__init__.py +++ b/quokka/ext/__init__.py @@ -1,13 +1,12 @@ # coding: utf-8 -from flask.ext.mail import Mail - +from flask_mail import Mail from quokka.core.db import db from quokka.core.cache import cache from quokka.core.admin import configure_admin from . import (generic, babel, blueprints, error_handlers, context_processors, template_filters, before_request, views, themes, fixtures, - oauthlib, weasyprint, security) + oauthlib, weasyprint, security, development) def configure_extensions(app, admin): @@ -17,43 +16,23 @@ def configure_extensions(app, admin): Mail(app) error_handlers.configure(app) db.init_app(app) - - themes.configure(app, db) # Themes should be configured after db - + themes.configure(app) context_processors.configure(app) template_filters.configure(app) - security.configure(app, db) - fixtures.configure(app, db) - blueprints.load_from_packages(app) + # blueprints.load_from_packages(app) blueprints.load_from_folder(app) - - # enable .pdf support for posts weasyprint.configure(app) - configure_admin(app, admin) - - if app.config.get('DEBUG_TOOLBAR_ENABLED'): - try: - from flask_debugtoolbar import DebugToolbarExtension - DebugToolbarExtension(app) - except: - pass - + development.configure(app, admin) before_request.configure(app) views.configure(app) - oauthlib.configure(app) - - if app.config.get('SENTRY_ENABLED', False): - from .sentry import configure - configure(app) - return app -def configure_extensions_min(app, admin): +def configure_extensions_min(app, *args, **kwargs): db.init_app(app) security.init_app(app, db) return app diff --git a/quokka/ext/blueprints.py b/quokka/ext/blueprints.py index 5a8d2143a..60be30ad3 100644 --- a/quokka/ext/blueprints.py +++ b/quokka/ext/blueprints.py @@ -1,14 +1,11 @@ # coding: utf-8 import os import importlib -import random -import logging -from .commands_collector import CommandsCollector -logger = logging.getLogger() +from quokka.ext.commands_collector import CommandsCollector -def load_from_packages(app): - pass +# def load_from_packages(app): +# pass def load_from_folder(app): @@ -32,34 +29,30 @@ def load_from_folder(app): dir_list = os.listdir(path) mods = {} object_name = app.config.get('BLUEPRINTS_OBJECT_NAME', 'module') + module_file = app.config.get('BLUEPRINTS_MODULE_NAME', 'main') + blueprint_module = module_file + '.py' for fname in dir_list: if not os.path.exists(os.path.join(path, fname, 'DISABLED')) and \ os.path.isdir(os.path.join(path, fname)) and \ - os.path.exists(os.path.join(path, fname, '__init__.py')): + os.path.exists(os.path.join(path, fname, blueprint_module)): # register blueprint object - module_name = ".".join([base_module_name, fname]) + module_root = ".".join([base_module_name, fname]) + module_name = ".".join([module_root, module_file]) mods[fname] = importlib.import_module(module_name) blueprint = getattr(mods[fname], object_name) - - if blueprint.name not in app.blueprints: - app.register_blueprint(blueprint) - else: - blueprint.name += str(random.getrandbits(8)) - app.register_blueprint(blueprint) - logger.warning( - "CONFLICT:%s already registered, using %s", - fname, - blueprint.name - ) + app.logger.info("registering blueprint: %s" % blueprint.name) + app.register_blueprint(blueprint) # register admin try: - importlib.import_module(".".join([module_name, 'admin'])) - except ImportError: - logger.info("%s module does not define admin", fname) + importlib.import_module(".".join([module_root, 'admin'])) + except ImportError as e: + app.logger.info( + "%s module does not define admin or error: %s", fname, e + ) - logger.info("%s modules loaded", mods.keys()) + app.logger.info("%s modules loaded", mods.keys()) def blueprint_commands(app): diff --git a/quokka/ext/commands_collector.py b/quokka/ext/commands_collector.py index 722d26508..ff323fb02 100644 --- a/quokka/ext/commands_collector.py +++ b/quokka/ext/commands_collector.py @@ -16,7 +16,7 @@ def __init__(self, modules_path, base_module_name, **attrs): self.base_module_name = base_module_name self.modules_path = modules_path - def list_commands(self, ctx): + def list_commands(self, *args, **kwargs): commands = [] for _path, _dir, _ in os.walk(self.modules_path): if 'commands' not in _dir: @@ -25,7 +25,7 @@ def list_commands(self, ctx): if filename.endswith('.py') and filename != '__init__.py': cmd = filename[:-3] _, module_name = os.path.split(_path) - commands.append('{}_{}'.format(module_name, cmd)) + commands.append('{0}_{1}'.format(module_name, cmd)) commands.sort() return commands @@ -39,7 +39,7 @@ def get_command(self, ctx, name): module_name, command_name = splitted if not all([module_name, command_name]): return - module = '{}.{}.commands.{}'.format( + module = '{0}.{1}.commands.{2}'.format( self.base_module_name, module_name, command_name) diff --git a/quokka/ext/context_processors.py b/quokka/ext/context_processors.py index 0038fdfc5..2d274b5a5 100644 --- a/quokka/ext/context_processors.py +++ b/quokka/ext/context_processors.py @@ -1,7 +1,9 @@ # coding: utf-8 import datetime -from quokka.core.models import Channel, Config, Content, Link +from quokka.core.models.channel import Channel +from quokka.core.models.config import Config +from quokka.core.models.content import Content, Link def configure(app): diff --git a/quokka/ext/development.py b/quokka/ext/development.py new file mode 100644 index 000000000..70c6cc9e2 --- /dev/null +++ b/quokka/ext/development.py @@ -0,0 +1,28 @@ +# coding: utf-8 + + +def configure(app, admin=None): + if app.config.get('DEBUG_TOOLBAR_ENABLED'): + try: + from flask_debugtoolbar import DebugToolbarExtension + DebugToolbarExtension(app) + except ImportError: + app.logger.info('flask_debugtoolbar is not installed') + + if app.config.get('OPBEAT'): + try: + from opbeat.contrib.flask import Opbeat + Opbeat( + app, + logging=app.config.get('OPBEAT', {}).get('LOGGING', False) + ) + app.logger.info('opbeat configured!!!') + except ImportError: + app.logger.info('opbeat is not installed') + + if app.config.get('SENTRY_ENABLED', False): + try: + from raven.contrib.flask import Sentry + app.sentry = Sentry(app) + except ImportError: + app.logger.info('sentry, raven is not installed') diff --git a/quokka/ext/error_handlers.py b/quokka/ext/error_handlers.py index 78685cdab..56244ecd6 100644 --- a/quokka/ext/error_handlers.py +++ b/quokka/ext/error_handlers.py @@ -4,7 +4,7 @@ def configure(app): @app.errorhandler(403) - def forbidden_page(error): + def forbidden_page(*args, **kwargs): """ The server understood the request, but is refusing to fulfill it. Authorization will not help and the request SHOULD NOT be repeated. @@ -18,7 +18,7 @@ def forbidden_page(error): return render_template("errors/access_forbidden.html"), 403 @app.errorhandler(404) - def page_not_found(error): + def page_not_found(*args, **kwargs): """ The server has not found anything matching the Request-URI. No indication @@ -35,7 +35,7 @@ def page_not_found(error): return render_template("errors/page_not_found.html"), 404 @app.errorhandler(405) - def method_not_allowed_page(error): + def method_not_allowed_page(*args, **kwargs): """ The method specified in the Request-Line is not allowed for the resource @@ -46,11 +46,11 @@ def method_not_allowed_page(error): return render_template("errors/method_not_allowed.html"), 405 @app.errorhandler(500) - def server_error_page(error): + def server_error_page(*args, **kwargs): return render_template("errors/server_error.html"), 500 # URLBUILD Error Handlers - def admin_icons_error_handler(error, endpoint, values): + def admin_icons_error_handler(error, endpoint, *args, **kwargs): "when some of default dashboard button is deactivated, avoids error" if endpoint in [item[0] for item in app.config.get('ADMIN_ICONS', [])]: return '/admin' diff --git a/quokka/ext/fixtures.py b/quokka/ext/fixtures.py index e86fa267e..81d70790a 100644 --- a/quokka/ext/fixtures.py +++ b/quokka/ext/fixtures.py @@ -1,6 +1,7 @@ # coding: utf-8 from quokka.utils.populate import Populate -from quokka.core.models import Quokka +from quokka.core.models.config import Quokka +from quokka.core.models.config import Config def configure(app, db): @@ -12,15 +13,27 @@ def configure(app, db): if not is_installed: app.logger.info("Loading fixtures") - populate = Populate(db, filepath=app.config.get('POPULATE_FILEPATH')) - populate.create_configs() - populate.create_purposes() - populate.create_channel_types() - populate.create_base_channels() + populate = Populate( + db, + filepath=app.config.get('POPULATE_FILEPATH'), + first_install=True + ) try: - with app.app_context(): - user_data, user_obj = populate.create_initial_superuser() - populate.create_initial_post(user_data, user_obj) - except: - app.logger.warning("Could not create initial user and post") - Quokka.objects.create(slug="is_installed") + populate.create_configs() + populate.create_purposes() + populate.create_channel_types() + populate.create_base_channels() + populate.role("admin") + populate.role("author") + try: + with app.app_context(): + user_data, user_obj = populate.create_initial_superuser() + populate.create_initial_post(user_data, user_obj) + except Exception as e: + app.logger.warning("Cant create initial user and post: %s" % e) + except Exception as e: + app.logger.error("Error loading fixtures, try again - %s" % e) + populate.reset() + Config.objects.delete() + else: + Quokka.objects.create(slug="is_installed") diff --git a/quokka/ext/oauthlib.py b/quokka/ext/oauthlib.py index 25675a0b3..bb464c4fb 100644 --- a/quokka/ext/oauthlib.py +++ b/quokka/ext/oauthlib.py @@ -42,7 +42,7 @@ def configure(app): } """ - app.add_url_rule( + app.add_quokka_url_rule( '/accounts/oauth/login//', 'oauth_login', oauth_login @@ -66,7 +66,7 @@ def configure(app): setattr(app, provider_name, oauth_app) - app.add_url_rule( + app.add_quokka_url_rule( '/accounts/oauth/authorized/{0}/'.format(provider), '{0}_authorized'.format(provider), oauth_app.authorized_handler(make_oauth_handler(provider)) diff --git a/quokka/ext/sentry.py b/quokka/ext/sentry.py deleted file mode 100644 index 2542e49dc..000000000 --- a/quokka/ext/sentry.py +++ /dev/null @@ -1,6 +0,0 @@ -# coding: utf-8 - - -def configure(app): - from raven.contrib.flask import Sentry - app.sentry = Sentry(app) diff --git a/quokka/ext/template_filters.py b/quokka/ext/template_filters.py index 2808ea6b4..31b4bdc80 100644 --- a/quokka/ext/template_filters.py +++ b/quokka/ext/template_filters.py @@ -7,7 +7,7 @@ from flask import Blueprint from werkzeug.routing import Rule from quokka.core.app import QuokkaModule -from quokka.core.models import Content +from quokka.core.models.content import Content from quokka_themes import Theme from pymongo.mongo_client import MongoClient diff --git a/quokka/ext/themes.py b/quokka/ext/themes.py index 206bee566..a88dbf9ce 100644 --- a/quokka/ext/themes.py +++ b/quokka/ext/themes.py @@ -3,15 +3,6 @@ from quokka_themes import Themes -def configure(app, db=None): +def configure(app): themes = Themes() themes.init_themes(app, app_identifier="quokka") - - # The code below is no longer needed since config reads database - # try: - # from quokka.core.models import Config - # s = Config.objects.get(group='settings') - # settings = {i.name: i.value for i in s.values} - # app.config.update(settings) - # except Exception as e: - # app.logger.error(str(e)) diff --git a/quokka/ext/views.py b/quokka/ext/views.py index aebe3df2a..301186644 100644 --- a/quokka/ext/views.py +++ b/quokka/ext/views.py @@ -9,8 +9,8 @@ ContentList, TagList ) -from quokka.core.views import TagAtom, FeedAtom, TagRss, FeedRss -from quokka.core.models import Channel +from quokka.core.views import TagAtom, FeedAtom, TagRss, FeedRss, SiteMap +from quokka.core.models.channel import Channel @roles_accepted('admin', 'developer') @@ -40,47 +40,52 @@ def static_from_root(): def configure(app): - app.add_url_rule('/mediafiles/', view_func=media) - app.add_url_rule('/template_files/', - view_func=template_files) - app.add_url_rule('/theme_template_files//', - view_func=theme_template_files) + app.add_quokka_url_rule('/sitemap.xml', + view_func=SiteMap.as_view('sitemap')) + app.add_quokka_url_rule('/mediafiles/', view_func=media) + app.add_quokka_url_rule('/template_files/', + view_func=template_files) + app.add_quokka_url_rule( + '/theme_template_files//', + view_func=theme_template_files + ) for filepath in app.config.get('MAP_STATIC_ROOT', []): - app.add_url_rule(filepath, view_func=static_from_root) + app.add_quokka_url_rule(filepath, view_func=static_from_root) # Match content detail, .html added to distinguish from channels # better way? how? content_extension = app.config.get("CONTENT_EXTENSION", "html") - app.add_url_rule('/.{0}'.format(content_extension), - view_func=ContentDetail.as_view('detail')) + app.add_quokka_url_rule('/.{0}'.format(content_extension), + view_func=ContentDetail.as_view('detail')) # Draft preview - app.add_url_rule('/.preview', - view_func=ContentDetailPreview.as_view('preview')) + app.add_quokka_url_rule('/.preview', + view_func=ContentDetailPreview.as_view('preview')) # Atom Feed - app.add_url_rule( + app.add_quokka_url_rule( '/.atom', view_func=FeedAtom.as_view('atom_list') ) - app.add_url_rule( + app.add_quokka_url_rule( '/tag/.atom', view_func=TagAtom.as_view('atom_tag') ) # RSS Feed - app.add_url_rule( + app.add_quokka_url_rule( '/.xml', view_func=FeedRss.as_view('rss_list') ) - app.add_url_rule('/tag/.xml', view_func=TagRss.as_view('rss_tag')) + app.add_quokka_url_rule('/tag/.xml', + view_func=TagRss.as_view('rss_tag')) # Tag list - app.add_url_rule('/tag//', view_func=TagList.as_view('tag')) + app.add_quokka_url_rule('/tag//', view_func=TagList.as_view('tag')) # Match channels by its long_slug mpath - app.add_url_rule('//', - view_func=ContentList.as_view('list')) + app.add_quokka_url_rule('//', + view_func=ContentList.as_view('list')) # Home page - app.add_url_rule( + app.add_quokka_url_rule( '/', view_func=ContentList.as_view('home'), defaults={"long_slug": Channel.get_homepage('long_slug') or "home"} diff --git a/quokka/ext/weasyprint.py b/quokka/ext/weasyprint.py index 66d0333de..b53e3c921 100644 --- a/quokka/ext/weasyprint.py +++ b/quokka/ext/weasyprint.py @@ -31,6 +31,8 @@ def configure(app): if app.config.get('ENABLE_TO_PDF', False) and not import_error: def render_to_pdf(long_slug): - return render_pdf(url_for('detail', long_slug=long_slug)) + return render_pdf(url_for('quokka.core.detail', + long_slug=long_slug)) - app.add_url_rule('/.pdf', view_func=render_to_pdf) + app.add_quokka_url_rule('/.pdf', + view_func=render_to_pdf) diff --git a/quokka/modules/accounts/__init__.py b/quokka/modules/accounts/__init__.py index af6dea8f8..e69de29bb 100644 --- a/quokka/modules/accounts/__init__.py +++ b/quokka/modules/accounts/__init__.py @@ -1,8 +0,0 @@ -# coding: utf8 -from quokka.core.app import QuokkaModule -from .views import SwatchView - - -module = QuokkaModule('accounts', __name__, template_folder='templates') -module.add_url_rule('/set_swatch/', - view_func=SwatchView.as_view('set_swatch')) diff --git a/quokka/modules/accounts/admin.py b/quokka/modules/accounts/admin.py index 9d7b4b759..13903e7d2 100644 --- a/quokka/modules/accounts/admin.py +++ b/quokka/modules/accounts/admin.py @@ -3,9 +3,10 @@ from wtforms.widgets import PasswordInput from quokka import admin from quokka.core.admin.models import ModelAdmin -from .models import Role, User, Connection from quokka.utils.translation import _l +from .models import Role, User, Connection + class UserAdmin(ModelAdmin): roles_accepted = ('admin',) @@ -16,7 +17,9 @@ class UserAdmin(ModelAdmin): 'confirmed_at', 'last_login_at', 'current_login_at', 'last_login_ip', 'current_login_ip', 'login_count', 'tagline', - 'gravatar_email', 'bio', 'links', 'values') + 'gravatar_email', 'use_avatar_from', + 'avatar_file_path', 'avatar_url', + 'bio', 'links', 'values') form_extra_fields = { "newpassword": TextField(widget=PasswordInput()) diff --git a/quokka/modules/accounts/main.py b/quokka/modules/accounts/main.py new file mode 100644 index 000000000..7afe9bcff --- /dev/null +++ b/quokka/modules/accounts/main.py @@ -0,0 +1,12 @@ +# coding: utf8 +from quokka.core.app import QuokkaModule +from .views import SwatchView, ProfileEditView, ProfileView + + +module = QuokkaModule('accounts', __name__, template_folder='templates') +module.add_url_rule('/accounts/set_swatch/', + view_func=SwatchView.as_view('set_swatch')) +module.add_url_rule('/accounts/profile//', + view_func=ProfileView.as_view('profile')) +module.add_url_rule('/accounts/profile/edit/', + view_func=ProfileEditView.as_view('profile_edit')) diff --git a/quokka/modules/accounts/models.py b/quokka/modules/accounts/models.py index 9b417a490..7b487785c 100644 --- a/quokka/modules/accounts/models.py +++ b/quokka/modules/accounts/models.py @@ -1,15 +1,20 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - +import logging from random import randint +from flask import url_for from quokka.core.db import db -from quokka.core.base_models.custom_values import HasCustomValue +from quokka.core.models.custom_values import HasCustomValue from quokka.utils.text import abbreviate, slugify from flask.ext.security import UserMixin, RoleMixin from flask.ext.security.utils import encrypt_password +from flask_gravatar import Gravatar from .utils import ThemeChanger +logger = logging.getLogger() + + # Auth class Role(db.Document, ThemeChanger, HasCustomValue, RoleMixin): @@ -34,6 +39,9 @@ class UserLink(db.EmbeddedDocument): css_class = db.StringField(max_length=50) order = db.IntField(default=0) + def __unicode__(self): + return u"{0} - {1}".format(self.title, self.link) + class User(db.DynamicDocument, ThemeChanger, HasCustomValue, UserMixin): name = db.StringField(max_length=255) @@ -59,7 +67,42 @@ class User(db.DynamicDocument, ThemeChanger, HasCustomValue, UserMixin): tagline = db.StringField(max_length=255) bio = db.StringField() links = db.ListField(db.EmbeddedDocumentField(UserLink)) + + use_avatar_from = db.StringField( + choices=( + ("gravatar", "gravatar"), + ("url", "url"), + ("upload", "upload"), + ("facebook", "facebook") + ), + default='gravatar' + ) gravatar_email = db.EmailField(max_length=255) + avatar_file_path = db.StringField() + avatar_url = db.StringField(max_length=255) + + def get_avatar_url(self, *args, **kwargs): + if self.use_avatar_from == 'url': + return self.avatar_url + elif self.use_avatar_from == 'upload': + return url_for( + 'quokka.core.media', filename=self.avatar_file_path + ) + elif self.use_avatar_from == 'facebook': + try: + return Connection.objects( + provider_id='facebook', + user_id=self.id, + ).first().image_url + except Exception as e: + logger.warning( + '%s use_avatar_from is set to facebook but: Error: %s' % ( + self.display_name, str(e) + ) + ) + return Gravatar()( + self.get_gravatar_email(), *args, **kwargs + ) @property def summary(self): @@ -71,22 +114,17 @@ def get_gravatar_email(self): def clean(self, *args, **kwargs): if not self.username: self.username = User.generate_username(self.name) - - try: - super(User, self).clean(*args, **kwargs) - except: - pass + super(User, self).clean(*args, **kwargs) @classmethod - def generate_username(cls, name): - # username = email.lower() - # for item in ['@', '.', '-', '+']: - # username = username.replace(item, '_') - # return username + def generate_username(cls, name, user=None): name = name or '' username = slugify(name) - if cls.objects.filter(username=username).count(): - username = "{}{}".format(username, randint(1, 1000)) + filters = {"username": username} + if user: + filters["id__ne"] = user.id + if cls.objects.filter(**filters).count(): + username = "{0}{1}".format(username, randint(1, 1000)) return username def set_password(self, password, save=False): diff --git a/quokka/modules/accounts/views.py b/quokka/modules/accounts/views.py index de3486ad1..0bf458cf8 100644 --- a/quokka/modules/accounts/views.py +++ b/quokka/modules/accounts/views.py @@ -1,9 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - -from flask import redirect, request, url_for +import os +from werkzeug import secure_filename +from flask import redirect, request, url_for, flash, current_app from flask.views import MethodView +from quokka.utils import get_current_user +from quokka.utils.upload import lazy_media_path +from flask.ext.security.utils import url_for_security from flask.ext.security import current_user +from flask.ext.mongoengine.wtf import model_form +from quokka.core.templates import render_template +from .models import User class SwatchView(MethodView): @@ -12,5 +19,116 @@ class SwatchView(MethodView): """ def post(self): - current_user.set_swatch(request.form.get('swatch')) + swatch = request.form.get('swatch') + current_user.set_swatch(swatch) + flash('Theme successfully changed to %s' % swatch, 'alert') return redirect(url_for('admin.index')) + + +class ProfileView(MethodView): + """ + Show User Profile + """ + + def get(self, user_id): + return render_template('accounts/profile.html') + + +class ProfileEditView(MethodView): + """ + Edit User Profile + """ + + form = model_form( + User, + only=[ + 'name', + 'email', + 'username', + 'tagline', + 'bio', + 'use_avatar_from', + 'gravatar_email', + 'avatar_url', + # 'links', # fix multifields + ] + ) + + @staticmethod + def needs_login(**kwargs): + if not current_user.is_authenticated(): + nex = kwargs.get( + 'next', + request.values.get( + 'next', + url_for('quokka.modules.accounts.profile_edit') + ) + ) + return redirect(url_for_security('login', next=nex)) + + def get(self): + user = get_current_user() + context = {} + for link in user.links: + context[link.icon] = link.link + return self.needs_login() or render_template( + 'accounts/profile_edit.html', + form=self.form(instance=user), + **context + ) + + def post(self): + form = self.form(request.form) + if form.validate(): + user = get_current_user() + avatar_file_path = user.avatar_file_path + avatar = request.files.get('avatar') + if avatar: + filename = secure_filename(avatar.filename) + avatar_file_path = os.path.join( + 'avatars', str(user.id), filename + ) + path = os.path.join(lazy_media_path(), avatar_file_path) + if not os.path.exists(os.path.dirname(path)): + os.makedirs(os.path.dirname(path), 0o777) + avatar.save(path) + form.populate_obj(user) + user.avatar_file_path = avatar_file_path + if avatar: + user.use_avatar_from = 'upload' + user.username = User.generate_username( + user.username or user.name, user=user + ) + + self.update_user_links(request.form, user) + + user.save() + flash('Profile saved!', 'alert') + return redirect( + request.args.get('next') or + url_for('quokka.modules.accounts.profile_edit') + ) + else: + flash('Error ocurred!', 'alert error') # form errors + return render_template('accounts/profile_edit.html', form=form) + + @staticmethod + def update_user_links(form, user): + for item in ['facebook', 'twitter', 'github', 'globe']: + data = form.get(item) + try: + if data: + user.links.update( + {'link': data}, icon=item + ) or user.links.create( + icon=item, + css_class=item, + title=item, + link=data + ) + else: + user.links.delete(icon=item) + except Exception as e: + current_app.logger.error( + 'Error updating user links: %s' % e + ) diff --git a/quokka/modules/authors/__init__.py b/quokka/modules/authors/__init__.py index 6d8f31849..e69de29bb 100644 --- a/quokka/modules/authors/__init__.py +++ b/quokka/modules/authors/__init__.py @@ -1,15 +0,0 @@ -# coding: utf-8 - -from quokka.core.app import QuokkaModule -from .views import AuthorListView, AuthorView -from .utils import get_author, get_authors, get_author_contents - - -module = QuokkaModule("authors", __name__, template_folder="templates") -module.add_url_rule('/author//', - view_func=AuthorView.as_view('author')) -module.add_url_rule('/authors/', - view_func=AuthorListView.as_view('authors')) -module.add_app_template_global(get_author) -module.add_app_template_global(get_authors) -module.add_app_template_global(get_author_contents) diff --git a/quokka/modules/authors/main.py b/quokka/modules/authors/main.py new file mode 100644 index 000000000..6d8f31849 --- /dev/null +++ b/quokka/modules/authors/main.py @@ -0,0 +1,15 @@ +# coding: utf-8 + +from quokka.core.app import QuokkaModule +from .views import AuthorListView, AuthorView +from .utils import get_author, get_authors, get_author_contents + + +module = QuokkaModule("authors", __name__, template_folder="templates") +module.add_url_rule('/author//', + view_func=AuthorView.as_view('author')) +module.add_url_rule('/authors/', + view_func=AuthorListView.as_view('authors')) +module.add_app_template_global(get_author) +module.add_app_template_global(get_authors) +module.add_app_template_global(get_author_contents) diff --git a/quokka/modules/authors/utils.py b/quokka/modules/authors/utils.py index bc22a9136..760b1d6ec 100644 --- a/quokka/modules/authors/utils.py +++ b/quokka/modules/authors/utils.py @@ -2,7 +2,7 @@ import datetime from flask import current_app, request -from quokka.core.models import Content +from quokka.core.models.content import Content from quokka.modules.accounts.models import User, Role @@ -35,10 +35,10 @@ def get_author_contents(author): def get_authors(*args, **kwargs): authors = User.objects.filter( roles__in=Role.objects.filter( - name__in=['admin', 'author'], + name__in=['author'], **kwargs ) - ) + ).order_by(current_app.config.get("AUTHORS_ORDER", "name")) disabled_pagination = False if not current_app.config.get("AUTHORS_PAGINATION_ENABLED", True): diff --git a/quokka/modules/comments/__init__.py b/quokka/modules/comments/__init__.py index bb6f41fc8..e69de29bb 100644 --- a/quokka/modules/comments/__init__.py +++ b/quokka/modules/comments/__init__.py @@ -1,28 +0,0 @@ -# coding: utf-8 - -from quokka.core.app import QuokkaModule -from .views import CommentView -from .models import Comment - - -module = QuokkaModule("comments", __name__, template_folder="templates") -module.add_url_rule('/comment//', - view_func=CommentView.as_view('comment')) - - -def get_comment(**kwargs): - try: - return Comment.objects.get(**kwargs) - except: - return None - - -def get_comments(limit=None, order_by="-created_at", **kwargs): - contents = Comment.objects.filter(**kwargs).order_by(order_by) - if limit: - contents = contents[:limit] - return contents - - -module.add_app_template_global(get_comment) -module.add_app_template_global(get_comments) diff --git a/quokka/modules/comments/main.py b/quokka/modules/comments/main.py new file mode 100644 index 000000000..bb6f41fc8 --- /dev/null +++ b/quokka/modules/comments/main.py @@ -0,0 +1,28 @@ +# coding: utf-8 + +from quokka.core.app import QuokkaModule +from .views import CommentView +from .models import Comment + + +module = QuokkaModule("comments", __name__, template_folder="templates") +module.add_url_rule('/comment//', + view_func=CommentView.as_view('comment')) + + +def get_comment(**kwargs): + try: + return Comment.objects.get(**kwargs) + except: + return None + + +def get_comments(limit=None, order_by="-created_at", **kwargs): + contents = Comment.objects.filter(**kwargs).order_by(order_by) + if limit: + contents = contents[:limit] + return contents + + +module.add_app_template_global(get_comment) +module.add_app_template_global(get_comments) diff --git a/quokka/modules/comments/models.py b/quokka/modules/comments/models.py index 3577d3b15..d86bca4d3 100644 --- a/quokka/modules/comments/models.py +++ b/quokka/modules/comments/models.py @@ -1,7 +1,7 @@ # coding: utf-8 import uuid from quokka.core.db import db -from quokka.core.models import Publishable +from quokka.core.models.signature import Publishable from quokka.utils.settings import get_setting_value @@ -40,7 +40,7 @@ def __unicode__(self): return u"{0} - {1}...".format(self.author_name, self.body[:15]) def get_canonical_url(self): - return "/{}.{}".format( + return "/{0}.{1}".format( self.path, get_setting_value('CONTENT_EXTENSION', 'html') ) if not self.path.startswith("/") else self.path diff --git a/quokka/modules/comments/views.py b/quokka/modules/comments/views.py index 3fadd3ddb..703f690e8 100644 --- a/quokka/modules/comments/views.py +++ b/quokka/modules/comments/views.py @@ -16,7 +16,8 @@ class CommentView(MethodView): only=['author_name', 'author_email', 'body'] ) - def render_context(self, path, form): + @staticmethod + def render_context(path, form): comments = Comment.objects(path=path, published=True) return render_template('content/comments.html', comments=comments, diff --git a/quokka/modules/media/README.md b/quokka/modules/media/README.md deleted file mode 100644 index 79e7a816f..000000000 --- a/quokka/modules/media/README.md +++ /dev/null @@ -1,9 +0,0 @@ -quokka-media -================== - -# Quokka Media Management Module | Version 0.1.0 - -Multimedia management, Images, Galleries, Audio and Video - -- http://github.com/pythonhub/quokka-media -- by Bruno Rocha \ No newline at end of file diff --git a/quokka/modules/media/__init__.py b/quokka/modules/media/__init__.py index 82fea9999..e69de29bb 100644 --- a/quokka/modules/media/__init__.py +++ b/quokka/modules/media/__init__.py @@ -1,9 +0,0 @@ -# coding: utf-8 - -from quokka.core.app import QuokkaModule -module = QuokkaModule("media", __name__, template_folder="templates") - -# Register the urls if needed -# from .views import ListView, DetailView -# module.add_url_rule('/media/', view_func=ListView.as_view('list')) -# module.add_url_rule('/media//', view_func=DetailView.as_view('detail')) diff --git a/quokka/modules/media/admin.py b/quokka/modules/media/admin.py index e1e7ca9fd..5a077d5cb 100644 --- a/quokka/modules/media/admin.py +++ b/quokka/modules/media/admin.py @@ -6,7 +6,7 @@ from quokka import admin from quokka.utils.settings import get_setting_value -from quokka.core.models import Channel +from quokka.core.models.channel import Channel from quokka.core.admin.models import ModelAdmin from quokka.core.admin.fields import ImageUploadField from quokka.utils.upload import dated_path, lazy_media_path @@ -19,7 +19,7 @@ class MediaAdmin(ModelAdmin): roles_accepted = ('admin', 'editor', 'author') - column_list = ('title', 'path', 'published') + column_list = ('title', 'full_path', 'published') form_columns = ['title', 'slug', 'path', 'channel', 'content_format', 'summary', 'comments_enabled', 'published'] @@ -91,17 +91,18 @@ class AudioAdmin(FileAdmin): class ImageAdmin(MediaAdmin): roles_accepted = ('admin', 'editor', 'author') - column_list = ('title', 'path', 'thumb', 'published') + column_list = ('title', 'full_path', 'thumb', 'published') form_columns = ['title', 'slug', 'path', 'channel', 'content_format', 'comments_enabled', 'summary', 'published'] - def _list_thumbnail(self, context, model, name): + @staticmethod + def _list_thumbnail(context, model, name): if not model.path: return '' return Markup( '' % url_for( - 'media', + 'quokka.core.media', filename=form.thumbgen_filename(model.path) ) ) @@ -116,7 +117,7 @@ def _list_thumbnail(self, context, model, name): base_path=lazy_media_path(), thumbnail_size=get_setting_value('MEDIA_IMAGE_THUMB_SIZE', default=(200, 200, True)), - endpoint="media", + endpoint="quokka.core.media", namegen=dated_path, permission=0o777, allowed_extensions="MEDIA_IMAGE_ALLOWED_EXTENSIONS", diff --git a/quokka/modules/media/main.py b/quokka/modules/media/main.py new file mode 100644 index 000000000..82fea9999 --- /dev/null +++ b/quokka/modules/media/main.py @@ -0,0 +1,9 @@ +# coding: utf-8 + +from quokka.core.app import QuokkaModule +module = QuokkaModule("media", __name__, template_folder="templates") + +# Register the urls if needed +# from .views import ListView, DetailView +# module.add_url_rule('/media/', view_func=ListView.as_view('list')) +# module.add_url_rule('/media//', view_func=DetailView.as_view('detail')) diff --git a/quokka/modules/media/models.py b/quokka/modules/media/models.py index 707c566fb..91f280893 100644 --- a/quokka/modules/media/models.py +++ b/quokka/modules/media/models.py @@ -1,9 +1,14 @@ # coding: utf-8 import logging +from flask import url_for +from jinja2 import Markup +from flask_admin import form + from quokka.core.db import db -from quokka.core.models import Content, Channel -from flask.ext.admin import form +from quokka.core.models.channel import Channel +from quokka.core.models.content import Content + from .controller import MediaController logger = logging.getLogger() @@ -21,6 +26,15 @@ class Media(MediaController, Content): 'allow_inheritance': True } + @property + def full_path(self): + return Markup( + "{path}".format( + full=url_for('quokka.core.media', filename=self.path), + path=self.path + ) + ) + @classmethod def get_default_channel(cls): default_channel = cls.DEFAULT_CHANNEL diff --git a/quokka/modules/media/views.py b/quokka/modules/media/views.py index 5213c6eb5..86c924b66 100644 --- a/quokka/modules/media/views.py +++ b/quokka/modules/media/views.py @@ -20,7 +20,8 @@ def get(self): class DetailView(MethodView): - def get_context(self, slug): + @staticmethod + def get_context(slug): media = Media.objects.get_or_404(slug=slug) context = { diff --git a/quokka/modules/posts/__init__.py b/quokka/modules/posts/__init__.py index d588ab1d4..e69de29bb 100644 --- a/quokka/modules/posts/__init__.py +++ b/quokka/modules/posts/__init__.py @@ -1,12 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from quokka.core.app import QuokkaModule - -module = QuokkaModule('posts', __name__, template_folder='templates') - -# Register the urls if needed -# in this case there is no need to register any specific url -# from .views import ListView, DetailView -# module.add_url_rule('/posts/', view_func=ListView.as_view('list')) -# module.add_url_rule('/posts//', view_func=DetailView.as_view('detail')) diff --git a/quokka/modules/posts/main.py b/quokka/modules/posts/main.py new file mode 100644 index 000000000..d588ab1d4 --- /dev/null +++ b/quokka/modules/posts/main.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from quokka.core.app import QuokkaModule + +module = QuokkaModule('posts', __name__, template_folder='templates') + +# Register the urls if needed +# in this case there is no need to register any specific url +# from .views import ListView, DetailView +# module.add_url_rule('/posts/', view_func=ListView.as_view('list')) +# module.add_url_rule('/posts//', view_func=DetailView.as_view('detail')) diff --git a/quokka/modules/posts/models.py b/quokka/modules/posts/models.py index 82376af4c..62811a445 100644 --- a/quokka/modules/posts/models.py +++ b/quokka/modules/posts/models.py @@ -2,9 +2,9 @@ # -*- coding: utf-8 -*- from quokka.core.db import db -from quokka.core.models import Content +from quokka.core.models.content import Content class Post(Content): - # URL_NAMESPACE = 'posts.detail' + # URL_NAMESPACE = 'quokka.modules.posts.detail' body = db.StringField(required=True) diff --git a/quokka/modules/posts/tests/test_model.py b/quokka/modules/posts/tests/test_model.py index fa52bf4c8..6a38c49c9 100644 --- a/quokka/modules/posts/tests/test_model.py +++ b/quokka/modules/posts/tests/test_model.py @@ -2,7 +2,7 @@ import sys from quokka.core.tests import BaseTestCase -from quokka.core.models import Channel +from quokka.core.models.channel import Channel from ..models import Post diff --git a/quokka/settings.py b/quokka/settings.py index e19767f29..ab3c52ae4 100644 --- a/quokka/settings.py +++ b/quokka/settings.py @@ -40,10 +40,13 @@ """ Not needed by flask, but those root folders are used -by FLask-Admin file manager +by FLask-Admin file manager and Media module """ PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + +# If you need different folder to save media files MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'mediafiles') + STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') ROOT_DIR = os.path.abspath(os.path.join(PROJECT_ROOT, '..')) @@ -56,7 +59,7 @@ Files on MAP_STATIC_ROOT will be served from /static/ example: /static/favicon.ico will be served by site.com/favicon.ico """ -MAP_STATIC_ROOT = ('/robots.txt', '/sitemap.xml', '/favicon.ico') +MAP_STATIC_ROOT = ('/robots.txt', '/favicon.ico') """ @@ -70,7 +73,9 @@ just develop or download and drop in your modules folder by default it is in /modules, you can change if needed """ + BLUEPRINTS_PATH = 'modules' +BLUEPRINTS_MODULE_NAME = 'main' BLUEPRINTS_OBJECT_NAME = 'module' """ @@ -92,15 +97,6 @@ ) FILE_ADMIN = [ - { - "name": "Template files", - "category": "Files", - "path": os.path.join(PROJECT_ROOT, 'templates'), - "url": "/template_files/", # create nginx rule - "endpoint": "template_files", - "roles_accepted": ("admin", "editor"), - "editable_extensions": DEFAULT_EDITABLE_EXTENSIONS - }, { "name": "Static files", "category": "Files", @@ -246,6 +242,13 @@ MEDIA_VIDEO_ALLOWED_EXTENSIONS = ('avi', 'mp4', 'mpeg') MEDIA_FILE_ALLOWED_EXTENSIONS = ('pdf', 'txt', 'doc', 'docx', 'xls', 'xmlsx') +""" +Quokka-Themes checks `THEME_PATHS` configuration variable to find +directories that contain themes. The theme's identifier in info.json +must match the name of its directory. +""" +# THEME_PATHS = '/etc/themes/' + # default admin THEME ADMIN_THEME = 'admin' """ @@ -275,7 +278,7 @@ SENTRY_ENABLED = False SENTRY_DSN = "" -# html or markdown +# html or markdown or plaintext DEFAULT_TEXT_FORMAT = "html" "Shortner urls configuration" @@ -284,9 +287,67 @@ "Note: if you enable shortener you have to define a SERVER_NAME" # SERVER_NAME = 'localhost' +"Redirect aliases is enabled?" +ALIASES_ENABLED = True + +""" +ALIASES_MAP +keys are long_slug + keys should always start with / + & end with / or extension. +{ + "/team/": { + "alias_type": "endpoint|long_slug|url|string", + "to": "authors|/articles/science.html|http://t.co|'Hello'", + "published": True, + "available_at": "", + "available_until: "", + } +} +""" +ALIASES_MAP = {} + "Config shorter information" SHORTENER_SETTINGS = {"name": "BitlyShortener", "bitly_api_key": "R_7d84f09c68be4c749cac2a56ace2e73f", "bitly_token": "9964d1f9c8c8b4215f7690449f0980c4fe1a6906", "bitly_login": "quokkabitly"} + + +""" +Some HTTP proxies do not support arbitrary HTTP methods or newer HTTP methods +(such as PATCH). +In that case it’s possible to “proxy” HTTP methods through another HTTP method +in total violation of the protocol. + +The way this works is by letting the client do an HTTP POST request and +set the X-HTTP-Method-Override header and set the value to the intended +HTTP method (such as PATCH). +""" +HTTP_PROXY_METHOD_OVERRIDE = False + + +""" +https://opbeat.com is application monitoring tool +you can enable it but you need to install requirements/dev.txt +https://opbeat.com/docs/articles/get-started-with-flask/ + +OPBEAT = { + 'ORGANIZATION_ID': '', + 'APP_ID': '', + 'SECRET_TOKEN': '', + 'INCLUDE_PATHS': ['quokka'], + 'DEBUG': True, + 'LOGGING': False +} + +Notify Opbeat when a release has completed +$ curl https://intake.opbeat.com/api/v1/ + organizations//apps//releases/ \ + -H "Authorization: Bearer " \ + -d rev=`git log -n 1 --pretty=format:%H` \ + -d branch=`git rev-parse --abbrev-ref HEAD` \ + -d status=completed +""" +# OPBEAT = None diff --git a/quokka/tests/test_basic.py b/quokka/tests/test_basic.py index c024085e9..97b019cbe 100644 --- a/quokka/tests/test_basic.py +++ b/quokka/tests/test_basic.py @@ -64,7 +64,7 @@ def test_blog_including_related_has_only_3_posts(self): self.assertTrue(Post.objects(**only_blog_filter).count() == 3) def test_has_default_theme(self): - from quokka.core.models import Config + from quokka.core.models.config import Config self.assertTrue(Config.get('settings', 'DEFAULT_THEME') == 'pure') def test_app_has_admin(self): diff --git a/quokka/themes/admin/static/admin/css/bootstrap2/quokka_admin.css b/quokka/themes/admin/static/admin/css/bootstrap2/quokka_admin.css index 881c0516d..e8d4d19d6 100644 --- a/quokka/themes/admin/static/admin/css/bootstrap2/quokka_admin.css +++ b/quokka/themes/admin/static/admin/css/bootstrap2/quokka_admin.css @@ -47,3 +47,8 @@ */ #no-more-tables td:before { content: attr(data-title); } } + + +.controls .md-editor>textarea { + background: none; +} diff --git a/quokka/themes/admin/templates/admin/base.html b/quokka/themes/admin/templates/admin/base.html index a1bcae285..df31109e9 100644 --- a/quokka/themes/admin/templates/admin/base.html +++ b/quokka/themes/admin/templates/admin/base.html @@ -56,7 +56,7 @@ @@ -116,13 +117,13 @@ if (selected_format == null) { if (default_editor == 'markdown') { load_markdown_editor(); - } else { + } else if (default_editor == 'html') { load_html_editor(); } } else { if (selected_format.value == 'markdown') { load_markdown_editor(); - } else { + } else if (selected_format.value == 'html') { load_html_editor(); } @@ -130,9 +131,12 @@ if (selected_format.value == 'markdown') { destroy_html_editor(); load_markdown_editor(); - } else { + } else if (selected_format.value == 'html') { destroy_markdown_editor(); load_html_editor(); + } else { + destroy_markdown_editor(); + destroy_html_editor() } }, false); } diff --git a/quokka/themes/admin/templates/admin/index.html b/quokka/themes/admin/templates/admin/index.html index b79a8baa3..1debc22aa 100644 --- a/quokka/themes/admin/templates/admin/index.html +++ b/quokka/themes/admin/templates/admin/index.html @@ -4,7 +4,7 @@
-
+
{%if config.get('ADMIN_HEADER') %}{{config.get('ADMIN_HEADER')|safe}}{% endif %}

{{ admin_view.admin.name }}

{{_gettext('Latest Posts')}}:

@@ -15,7 +15,7 @@

{{_gettext('Latest Posts')}}:

| {{_gettext('Edit')}} - + {{_gettext('View') if post.published else _gettext('Preview')}} {% else %} @@ -24,25 +24,25 @@

{{_gettext('Latest Posts')}}:

-
-

{{_gettext('Latest Comments')}}:

-
    - {% for comment in get_comments(limit=3) %} -
  • - {{ comment.author_name }} {{ _gettext('says') }}: -
    {{ comment.body[:140] }} -
    - - - - -
  • - {% else %} - {{ _gettext("No comments yet!") }} - {{ _gettext("Internal comment system is enabled, if you want to change it click in config button and change from 'internal' to 'disqus' in comments configuration and set your disqus_script.") }} - {% endfor %} -
-
+{#
#} +{#

{{_gettext('Latest Comments')}}:

#} +{#
    #} +{# {% for comment in get_comments(limit=3) %}#} +{#
  • #} +{# {{ comment.author_name }} {{ _gettext('says') }}:#} +{#
    {{ comment.body[:140] }}#} +{#
    #} +{# #} +{# #} +{# #} +{# #} +{#
  • #} +{# {% else %}#} +{# {{ _gettext("No comments yet!") }}#} +{# {{ _gettext("Internal comment system is enabled, if you want to change it click in config button and change from 'internal' to 'disqus' in comments configuration and set your disqus_script.") }}#} +{# {% endfor %}#} +{#
#} +{#
#}
@@ -60,7 +60,7 @@

{{_gettext('Latest Comments')}}:

-
+ + +
+ + {% for field in form %} + {% if field.type in ['CSRFTokenField', 'HiddenField'] %} + {{ field() }} + {% elif field.type == 'FieldList' %} + {{field()}} + {% else %} + {{ render_field_with_errors(field, class="form-control") }} + {% endif %} + {% endfor %} + + +
+ Personal links: +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/quokka/themes/pure/templates/authors/detail.html b/quokka/themes/pure/templates/authors/detail.html index 8400c6471..c425e1bc1 100644 --- a/quokka/themes/pure/templates/authors/detail.html +++ b/quokka/themes/pure/templates/authors/detail.html @@ -13,9 +13,9 @@ - + - + {% endblock meta %} {% block content %} @@ -26,7 +26,7 @@ {{Config.get('site', 'site_name')}} {{render_author_sidebar(author)}}

{{ author.bio }}

- ←Authors + ←Authors diff --git a/quokka/themes/pure/templates/authors/list.html b/quokka/themes/pure/templates/authors/list.html index 0885affc9..f7f561eb2 100644 --- a/quokka/themes/pure/templates/authors/list.html +++ b/quokka/themes/pure/templates/authors/list.html @@ -19,12 +19,12 @@

All authors

{% if author.email %}
- {{ author.display_name }} + {{ author.display_name }} -
{{ author.display_name }} +
{{ author.display_name }}
- {{author.get_value('position')}} + {{author.get_value('position') or ''}} diff --git a/quokka/themes/pure/templates/base.html b/quokka/themes/pure/templates/base.html index 469408fa2..e07070882 100644 --- a/quokka/themes/pure/templates/base.html +++ b/quokka/themes/pure/templates/base.html @@ -20,8 +20,8 @@ {% elif channel %} - - + + {% endif %} diff --git a/quokka/themes/pure/templates/content/comments.html b/quokka/themes/pure/templates/content/comments.html index eab0d0615..93c393fcf 100644 --- a/quokka/themes/pure/templates/content/comments.html +++ b/quokka/themes/pure/templates/content/comments.html @@ -10,7 +10,7 @@
{% if not Config.get('comments', 'requires_login') or current_user.is_authenticated() %} -
+
{% for field in form %} {% if field.type in ['CSRFTokenField', 'HiddenField'] %} diff --git a/quokka/themes/pure/templates/content/default_detail.html b/quokka/themes/pure/templates/content/default_detail.html index 14710531a..5f88d7411 100644 --- a/quokka/themes/pure/templates/content/default_detail.html +++ b/quokka/themes/pure/templates/content/default_detail.html @@ -34,7 +34,7 @@ - + {% endblock meta %} {% block content %} diff --git a/quokka/themes/pure/templates/content/tag_list.html b/quokka/themes/pure/templates/content/tag_list.html index 9a246c7e7..358debc2c 100644 --- a/quokka/themes/pure/templates/content/tag_list.html +++ b/quokka/themes/pure/templates/content/tag_list.html @@ -6,7 +6,7 @@ {% block seo_meta %} - + {% endblock %} {% block content %} diff --git a/quokka/themes/pure/templates/flashes.html b/quokka/themes/pure/templates/flashes.html new file mode 100644 index 000000000..e3c4681d0 --- /dev/null +++ b/quokka/themes/pure/templates/flashes.html @@ -0,0 +1,11 @@ +{% block flash_messages %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} +{% endblock %} diff --git a/quokka/themes/pure/templates/sidebar.html b/quokka/themes/pure/templates/sidebar.html index b23c09ade..0cfb99e56 100644 --- a/quokka/themes/pure/templates/sidebar.html +++ b/quokka/themes/pure/templates/sidebar.html @@ -7,6 +7,8 @@ {% endif %} > + {% include theme('flashes.html') %} +
diff --git a/quokka/themes/pure/templates/sitemap.xml b/quokka/themes/pure/templates/sitemap.xml new file mode 100644 index 000000000..6f4c3b9af --- /dev/null +++ b/quokka/themes/pure/templates/sitemap.xml @@ -0,0 +1,17 @@ +{%- macro render_item(item) -%} + + {{item.get_http_url()|safe}} + {{item.available_at.strftime('%Y-%m-%d')}} + daily + 0.2 + +{%- endmacro -%} + + + {%- for item in channels -%} + {{ render_item(item) }} + {%- endfor -%} + {%- for item in contents -%} + {{ render_item(item) }} + {%- endfor -%} + diff --git a/quokka/utils/__init__.py b/quokka/utils/__init__.py index 2e2a2c3dc..e17dd53f3 100644 --- a/quokka/utils/__init__.py +++ b/quokka/utils/__init__.py @@ -36,7 +36,8 @@ def get_current_user_for_models(): if not user.is_authenticated(): return None return user - except: + except Exception as e: + logger.info('Cant access is_authenticated method: %s' % e) return None @@ -45,32 +46,30 @@ def is_accessible(roles_accepted=None, user=None): if user.has_role('admin'): return True if roles_accepted: - accessible = any( - [user.has_role(role) for role in roles_accepted] - ) + accessible = any(user.has_role(role) for role in roles_accepted) return accessible return True def parse_conf_data(data): """ - $int $bool $float $json (for lists and dicts) + @int @bool @float @json (for lists and dicts) strings does not need converters export QUOKKA_DEFAULT_THEME='material' - export QUOKKA_DEBUG='$bool True' - export QUOKKA_DEBUG_TOOLBAR_ENABLED='$bool False' - export QUOKKA_PAGINATION_PER_PAGE='$int 20' - export QUOKKA_MONGODB_SETTINGS='$json {"DB": "quokka_db", "HOST": "mongo"}' - export QUOKKA_ALLOWED_EXTENSIONS='$json ["jpg", "png"]' + export QUOKKA_DEBUG='@bool True' + export QUOKKA_DEBUG_TOOLBAR_ENABLED='@bool False' + export QUOKKA_PAGINATION_PER_PAGE='@int 20' + export QUOKKA_MONGODB_SETTINGS='@json {"DB": "quokka_db", "HOST": "mongo"}' + export QUOKKA_ALLOWED_EXTENSIONS='@json ["jpg", "png"]' """ import json - true_values = ('t', 'true', 'enabled', '1', 'on') + true_values = ('t', 'true', 'enabled', '1', 'on', 'yes') converters = { - '$int': int, - '$float': float, - '$bool': lambda value: True if value.lower() in true_values else False, - '$json': json.loads + '@int': int, + '@float': float, + '@bool': lambda value: True if value.lower() in true_values else False, + '@json': json.loads } if data.startswith(tuple(converters.keys())): parts = data.partition(' ') diff --git a/quokka/utils/aliases.py b/quokka/utils/aliases.py new file mode 100644 index 000000000..810d8b4c5 --- /dev/null +++ b/quokka/utils/aliases.py @@ -0,0 +1,95 @@ +from flask import ( + redirect, url_for, request, current_app, render_template_string, abort +) +from flask.globals import _app_ctx_stack, _request_ctx_stack +from werkzeug.urls import url_parse +from quokka.core.templates import render_template + + +def dispatch_aliases(): + """ + When ALIASES_ENABLED == True + + This method handle 3 QuokkaCMS features: + 1. Fixed aliases + Alias is defined in ALIASES_MAP setting as a dictionary + 2. Managed Redirects + Alias defined in database + 3. Channel and Content aliases + Alias defined in specific channel or content + + ALIASES_MAP + keys are long_slug + keys should always start with / + & end with / or extension. + { + "/team/": { + "alias_type": "endpoint|long_slug|url|string|template", + "action": "redirect|render", + "to": "authors|/articles/science.html|http://t.co|'Hello'", + "published": True, + "available_at": "", + "available_until: "", + } + } + + - 'endpoint' and 'long_slug' by default are rendered + - 'url' is always redirect + - 'string' and 'template' are always rendered + """ + + app = current_app + aliases_map = app.config.get('ALIASES_MAP') + if aliases_map and request.path in aliases_map: + alias = aliases_map[request.path] + status = alias.get('status', 200) + if alias['alias_type'] == 'endpoint': + endpoint = alias['to'] + if alias.get('action') == 'redirect': + return redirect(url_for(endpoint, **request.args)) + else: # render + return app.process_response( + app.make_response( + app.view_functions[endpoint]() + ) + ) + elif alias['alias_type'] == 'long_slug': + long_slug = alias['to'] + if alias.get('action') == 'redirect': + return redirect(long_slug) # pass request.args ? + else: # render + endpoint = route_from(long_slug)[0] + return app.process_response( + app.make_response( + app.view_functions[endpoint]() + ) + ) + elif alias['alias_type'] == 'url': + return redirect(alias['to']) + elif alias['alias_type'] == 'string': + return render_template_string(alias['to']), status + elif alias['alias_type'] == 'template': + return render_template(alias['to']), status + + +def route_from(url, method=None): + appctx = _app_ctx_stack.top + reqctx = _request_ctx_stack.top + if appctx is None: + raise RuntimeError('Attempted to match a URL without the ' + 'application context being pushed. This has to be ' + 'executed when application context is available.') + + if reqctx is not None: + adapter = reqctx.url_adapter + else: + adapter = appctx.url_adapter + if adapter is None: + raise RuntimeError('Application was not able to create a URL ' + 'adapter for request independent URL matching. ' + 'You might be able to fix this by setting ' + 'the SERVER_NAME config variable.') + parsed = url_parse(url) + if parsed.netloc is not "" and parsed.netloc != adapter.server_name: + abort(404) + return adapter.match(parsed.path, method) diff --git a/quokka/utils/populate.py b/quokka/utils/populate.py index 5a34dece9..c52b6bf2e 100644 --- a/quokka/utils/populate.py +++ b/quokka/utils/populate.py @@ -3,9 +3,11 @@ import json import uuid -from quokka.core.models import Channel, ChannelType, SubContentPurpose, \ - Config, License -from quokka.core.base_models.custom_values import CustomValue +from quokka.core.models.subcontent import SubContentPurpose +from quokka.core.models.channel import Channel, ChannelType +from quokka.core.models.config import Config, Quokka +from quokka.core.models.content import License +from quokka.core.models.custom_values import CustomValue from quokka.modules.accounts.models import User, Role from quokka.modules.posts.models import Post @@ -24,8 +26,8 @@ def __init__(self, db, *args, **kwargs): self.purposes = {} self.custom_values = {} self.load_fixtures() - self.baseurl = self.kwargs.get('baseurl', None) - self.app = self.kwargs.get('app', None) + self.baseurl = self.kwargs.get('baseurl') + self.app = self.kwargs.get('app') def __call__(self, *args, **kwargs): if self.baseurl and self.app: @@ -120,7 +122,8 @@ def create_users(self, data=None): for data in self.users_data: self.create_user(data) - def create_config(self, data): + @staticmethod + def create_config(data): try: return Config.objects.get(group=data.get('group')) except: @@ -230,40 +233,40 @@ def create_purposes(self): self.create_purpose(purpose) def create_initial_post(self, user_data=None, user_obj=None): - post_data = { - "title": "Try Quokka CMS! write a post.", - "summary": ( + post_data = dict( + title="Try Quokka CMS! write a post.", + summary=( "Use default credentials to access " "/admin \r\n" "user: {user[email]} \r\n" "pass: {user[password]} \r\n" ).format(user=user_data), - "slug": "try-quokka-cms", - "tags": ["quokka"], - "body": ( - "## You can try Quokka ADMIN\r\n\r\n" - "Create some posts\r\n\r\n" - "> Use default credentials to access " - "[/admin](/admin) \r\n\r\n" - "- user: {user[email]}\r\n" - "- password: {user[password]}\r\n" - "> ATTENTION! Copy the credentials and delete this post" + slug="try-quokka-cms", + tags=["quokka"], + body=( + "## You can try Quokka ADMIN\r\n\r\n" + "Create some posts\r\n\r\n" + "> Use default credentials to access " + "[/admin](/admin) \r\n\r\n" + "- user: {user[email]}\r\n" + "- password: {user[password]}\r\n" + "> ATTENTION! Copy the credentials and delete this post" ).format(user=user_data), - "license": { - "title": "Creative Commons", - "link": "http://creativecommons.com", - "identifier": "creative_commons_by_nc_nd" - }, - "content_format": "markdown" - } + license=dict( + title="Creative Commons", + link="http://creativecommons.com", + identifier="creative_commons_by_nc_nd" + ), + content_format="markdown" + ) post_data['channel'] = self.channels.get("home") - post_data["created_by"] = user_obj or User.objects.first() + post_data["created_by"] = user_obj or self.users.get('author') post = self.create_post(post_data) return post def create_post(self, data): if not data.get('created_by'): - data['created_by'] = self.users.get('admin') + data['created_by'] = self.users.get('author') data['last_updated_by'] = data['created_by'] data['published'] = True @@ -298,3 +301,30 @@ def create_posts(self): except: self.create_channels() self.create_post(data) + + def reset(self): + Post.objects( + slug__in=[item['slug'] for item in self.json_data.get('posts')] + ).delete() + + SubContentPurpose.objects( + identifier__in=[ + item['identifier'] for item in self.json_data.get('purposes') + ] + ).delete() + + for channel in Channel.objects( + slug__in=[ + item['slug'] for item in self.json_data.get('channels')]): + for subchannel in channel.get_children(): + for subsubchannel in subchannel.get_children(): + subsubchannel.delete() + subchannel.delete() + channel.delete() + + User.objects( + email__in=[item['email'] for item in self.json_data.get('users')] + ).delete() + + if self.kwargs.get('first_install'): + Quokka.objects.delete() diff --git a/quokka/utils/settings.py b/quokka/utils/settings.py index c4bc8019b..268c7369d 100644 --- a/quokka/utils/settings.py +++ b/quokka/utils/settings.py @@ -1,7 +1,11 @@ +import logging +import quokka.core.models as m from flask import current_app, request from quokka.core.db import db from quokka.core.app import QuokkaApp +logger = logging.getLogger() + def create_app_min(config=None, test=False): app = QuokkaApp('quokka') @@ -10,9 +14,8 @@ def create_app_min(config=None, test=False): def get_site_url(): - from quokka.core.models import Config try: - from_site_config = Config.get('site', 'site_domain', None) + from_site_config = m.config.Config.get('site', 'site_domain', None) from_settings = get_setting_value('SERVER_NAME', None) if from_settings and not from_settings.startswith('http'): from_settings = 'http://%s/' % from_settings @@ -24,8 +27,8 @@ def get_site_url(): def get_setting_value(key, default=None): try: return current_app.config.get(key, default) - except RuntimeError: - pass + except RuntimeError as e: + logger.warning('current_app is inaccessible: %s' % e) try: app = create_app_min() diff --git a/requirements/dev.txt b/requirements/dev.txt index 83d64091b..896e7313d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,4 @@ Flask-DebugToolbar==0.10.0 -ipython==4.0.1 -ipdb==0.8.1 +ipython==4.1.2 +ipdb==0.9.0 +opbeat==3.2.2 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 986f46f4d..d2a687366 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -5,27 +5,33 @@ quokka-flask-htmlbuilder==0.10 awesome-slugify==1.6.5 blinker==1.4 Flask==0.10.1 -Flask-BabelEx==0.9.2 +Flask-BabelEx==0.9.3 Flask-Cache==0.13.1 Flask-Gravatar==0.4.2 Flask-Mistune==0.1.1 quokka-flask-mongoengine==0.7.4 Flask-OAuthlib==0.9.2 -flask-security==1.7.4 -Pillow==3.0.0 +# Flask-Login broke compatibility +flask-security==1.7.4 # rq.filter: <1.7.5 +# Pillow 3.0.0 raises --enable-zlib and --enable-jpeg error on some O.S +#Pillow==2.9.0 # rq.filter:<3.0.0 +Pillow==3.1.1 PyRSS2Gen==1.1 -requests==2.8.1 +requests==2.9.1 quokka-speaklater==1.3.1 pyshorteners==0.5.8 cached-property==1.3.0 shiftpy==0.1.3 -click==6.2 +click==6.3 # tempfix mongoengine==0.10.1 # rq.filter:<0.10.2 # mongoengine is not working yet with pymongo3 -pymongo==2.9.1 # rq.filter: <3 +pymongo==2.9.2 # rq.filter: <3 # There is a compatibility break in Flask-Login 0.3 -Flask-Login==0.2.11 # rq.filter: <0.3 +Flask-Login==0.2.11 # rq.filter: <0.3 + +# Mistune 0.7.1 broke base64; inline images +mistune==0.6 # rq.filter: <0.7 diff --git a/requirements/test.txt b/requirements/test.txt index be5097f36..26ce7001a 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,15 +1,15 @@ # testing coveralls mock==1.3.0 -pytest==2.8.3 -pytest-cov==2.2.0 +pytest==2.9.0 +pytest-cov==2.2.1 pytest-flask==0.10.0 Flask-Testing==0.4.2 # style check -flake8==2.5.0 +flake8==2.5.2 # rq.filter:<2.5.3 pep8-naming==0.3.3 flake8-debugger==1.4.0 -flake8-print==2.0.1 +flake8-print==2.0.2 flake8-todo==0.4 -radon==1.2.2 +radon==1.3.0