├── .coveragerc ├── .coveralls.yml ├── .gitattributes ├── .gitignore ├── .travis.yml ├── COPYING.txt ├── README.rst ├── bower.json ├── conftest.py ├── deploy_tools ├── drone.io_comands.sh ├── gunicorn-upstart.template.conf └── nginx.template.conf ├── doc ├── Makefile ├── conf.py ├── configuration.rst ├── index.rst └── readme_module.rst ├── fabfile.py ├── functional_tests ├── __init__.py ├── fixtures │ ├── items.json │ └── users.json ├── models.py └── test_functional.py ├── manage.py ├── pypo ├── __init__.py ├── settings.py ├── settings_local.py.template ├── urls.py └── wsgi.py ├── pytest.ini ├── readme ├── __init__.py ├── account_urls.py ├── admin.py ├── api.py ├── api_urls.py ├── download.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto__add_item.py │ ├── 0003_auto__add_field_item_owner.py │ ├── 0004_auto__del_field_item_slug.py │ ├── 0005_auto__add_field_item_readable_article.py │ ├── 0006_auto__chg_field_item_url.py │ ├── 0007_auto__add_itemtag__add_taggeditem.py │ ├── 0008_move_tags_to_custom_model.py │ ├── 0009_auto__add_userprofile.py │ ├── 0010_create_userprofiles.py │ ├── 0011_auto__add_field_item_safe_article.py │ ├── 0012_save_safe_article.py │ ├── 0013_auto__add_field_userprofile_new_window.py │ ├── 0014_auto__add_field_userprofile_items_per_page.py │ ├── 0015_auto__add_field_userprofile_show_excluded.py │ └── __init__.py ├── models.py ├── scrapers.py ├── search_indexes.py ├── serializers.py ├── signals.py ├── static │ ├── css │ │ ├── readme.css │ │ └── readme.less │ └── js │ │ └── readme.js ├── templates │ ├── base.html │ ├── entrance.html │ ├── readme │ │ ├── form_signin.html │ │ ├── invite.html │ │ ├── item_confirm_delete.html │ │ ├── item_detail.html │ │ ├── item_form.html │ │ ├── item_list.html │ │ ├── item_single.html │ │ ├── profile.html │ │ └── tests │ │ │ └── setup.html │ ├── registration │ │ ├── logged_out.html │ │ ├── login.html │ │ ├── password_change_done.html │ │ ├── password_change_form.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_confirm.html │ │ ├── password_reset_done.html │ │ ├── password_reset_email.html │ │ └── password_reset_form.html │ └── search │ │ ├── indexes │ │ └── readme │ │ │ └── item_text.txt │ │ └── search.html ├── test_item.py ├── test_readme.py └── views.py ├── requirements.txt ├── run_tests.sh └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source= 3 | readme 4 | pypo 5 | omit= 6 | pypo/wsgi.py 7 | readme/migrations/* 8 | readme/test_*.py 9 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: MkzlD6ojLvm8Mm6uimoYnIDGwMFxv2XJf 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | text eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/ 3 | pypo/settings_local.py 4 | pypo/whoosh_index/* 5 | whoosh_index_test 6 | doc/build 7 | doc/_build 8 | .coverage 9 | bower_components/* 10 | node_modules/* 11 | ghostdriver.log 12 | pypo.db 13 | pypo.egg-info 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.3" 4 | - "3.4" 5 | install: 6 | - pip install -r requirements.txt --use-mirrors 7 | - pip install coverage 8 | - pip install coveralls 9 | - pip install -e . 10 | env: 11 | - TEST_SUITE=js 12 | - TEST_SUITE=python WEBDRIVER=Firefox 13 | # - TEST_SUITE=python WEBDRIVER=PhantomJS 14 | script: ./run_tests.sh $TEST_SUITE 15 | before_script: 16 | - psql -c 'create database pypo;' -U postgres 17 | - npm install -g bower 18 | - bower install -f 19 | - python manage.py migrate 20 | before_install: 21 | - "export DISPLAY=:99.0" 22 | - "sh -e /etc/init.d/xvfb start" 23 | after_success: 24 | - coverage combine 25 | - coveralls 26 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Copyright 2013 Jens Kadenbach 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | About Pypo 2 | ========== 3 | 4 | |Build Status| 5 | 6 | 7 | Pypo is a self hosted bookmarking service like `Pocket`_. 8 | There also is a rudimentary android application and firefox 9 | extension to add and view the bookmarks. 10 | 11 | It's main components are built with: 12 | 13 | - Python 3 14 | - Postgresql 15 | - Django 16 | - readability-lxml 17 | - Whoosh 18 | - django-haystack 19 | - django-taggit 20 | - tld 21 | - South 22 | - requests 23 | - djangorestframework 24 | - py.test 25 | - bleach 26 | 27 | Documentation 28 | ------------- 29 | Full documentation can be found at `readthedocs`_ 30 | 31 | Features 32 | -------- 33 | 34 | - Adding links and fetch their summaries and titles 35 | - Links can have multiple tags 36 | - Search by title, url and tags 37 | - Filter by tags 38 | 39 | Installation 40 | ------------ 41 | 42 | 1. Create a virtualenv and 43 | 44 | .. code-block:: bash 45 | 46 | $ pip install -r requirements.txt 47 | $ pip install -e . 48 | 49 | 2. Setup a postgresql db 50 | 3. You can overwrite the default settings by creating a 51 | settings\_local.py next to pypo/settings.py . Do not directly edit 52 | the settings.py. 53 | 4. Install js modules with bower 54 | 55 | .. code-block:: bash 56 | 57 | $ npm install -g bower 58 | $ bower install 59 | 60 | 5. Install yuglify for js and css minifiy 61 | 62 | .. code-block:: bash 63 | 64 | $ npm install -g yuglify 65 | 66 | 6. Setup the database 67 | 68 | .. code-block:: bash 69 | 70 | $ ./manage.py syncdb 71 | $ ./manage.py migrate 72 | 73 | 7. Add a superuser 74 | 75 | .. code-block:: bash 76 | 77 | $ ./manage.py createsuperuser 78 | 79 | 8. Host the application, see `Deploying Django with WSGI`_ 80 | 9. Create normal users with the admin interface /admin 81 | 82 | Deploying 83 | --------- 84 | There is a fab file you can customize to you liking. It creates a virtualenv, 85 | sets up the directory structure and checks your current local commit out 86 | on the target machine. 87 | 88 | Requirements status 89 | ------------------ 90 | |Requirements Status| 91 | 92 | License 93 | ------- 94 | 95 | This project is licensed under the terms of the Apache License version 96 | 2. See COPYING.txt for details. 97 | 98 | .. _Pocket: http://www.getpocket.com 99 | .. _Deploying Django with WSGI: https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 100 | .. _readthedocs: http://pypo.readthedocs.org/ 101 | .. |Build Status| image:: https://travis-ci.org/audax/pypo.png?branch=master 102 | :target: https://travis-ci.org/audax/pypo 103 | .. |Requirements Status| image:: https://requires.io/github/audax/pypo/requirements.png?branch=master 104 | :target: https://requires.io/github/audax/pypo/requirements/?branch=master 105 | :alt: Requirements Status 106 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pypo", 3 | "version": "0.1.1", 4 | "homepage": "https://github.com/audax/pypo", 5 | "authors": [ 6 | "Jens Kadenbach " 7 | ], 8 | "description": "Django application to manage bookmarks", 9 | "keywords": [ 10 | "python", 11 | "django" 12 | ], 13 | "license": "Apache License, Version 2.0", 14 | "private": true, 15 | "ignore": [ 16 | "**/.*", 17 | "node_modules", 18 | "bower_components", 19 | "test", 20 | "tests" 21 | ], 22 | "dependencies": { 23 | "bootstrap-tokenfield": "~0.11.9", 24 | "jquery-ui": "~1.10.4", 25 | "qunit": "~1.14.0", 26 | "sinon": "~1.9.1", 27 | "qunit-phantomjs-runner": "jonkemp/qunit-phantomjs-runner#~1.2.0", 28 | "fontawesome": "~4.0.3", 29 | "PopConfirm": "audax/PopConfirm", 30 | "bootstrap": "~3.1.1", 31 | "bootswatch": "~3.1.1", 32 | "select2": "~3.4.6", 33 | "x-editable": "~1.5.1", 34 | "select2-bootstrap-css": "~1.3.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | 5 | import haystack 6 | from rest_framework.test import APIClient 7 | 8 | from django.core.management import call_command 9 | from unittest.mock import patch, Mock 10 | from readme.models import User, Item 11 | 12 | from django.conf import settings 13 | 14 | def pytest_configure(): 15 | # workaround to avoid django pipeline issue 16 | # refers to 17 | settings.STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage' 18 | 19 | QUEEN = 'queen with spaces änd umlauts' 20 | EXAMPLE_COM = 'http://www.example.com/' 21 | 22 | 23 | @pytest.fixture 24 | def user(db): 25 | try: 26 | user = User.objects.get(username='admin') 27 | except User.DoesNotExist: 28 | user = User.objects.create_user('admin', 'admin@example.com', 29 | 'password') 30 | user.is_staff = True 31 | user.is_superuser = True 32 | user.save() 33 | return user 34 | 35 | @pytest.fixture 36 | def other_user(db): 37 | try: 38 | user = User.objects.get(username='other_user') 39 | except User.DoesNotExist: 40 | user = User.objects.create_user('other_user', 'other_user@example.com', 41 | 'password') 42 | user.is_staff = True 43 | user.is_superuser = True 44 | user.save() 45 | return user 46 | 47 | @pytest.fixture 48 | def api_user(db): 49 | try: 50 | user = User.objects.get(username='dev') 51 | except User.DoesNotExist: 52 | user = User.objects.create_user('dev', 'dev@example.com', 53 | 'dev') 54 | user.is_staff = True 55 | user.is_superuser = True 56 | user.save() 57 | return user 58 | 59 | @pytest.fixture 60 | def user_client(client, user): 61 | client.login(username='admin', password='password') 62 | return client 63 | 64 | @pytest.fixture 65 | def api_client(api_user): 66 | client = APIClient() 67 | client.login(username='dev', password='dev') 68 | return client 69 | 70 | def add_example_item(user, tags=None): 71 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=user, readable_article="") 72 | if tags is not None: 73 | item.tags.add(*tags) 74 | item.save() 75 | return item 76 | 77 | @pytest.yield_fixture(scope='module') 78 | def tagged_items(db, user): 79 | items = [ 80 | add_example_item(user, ('fish', 'boxing')), 81 | add_example_item(user, ('fish', QUEEN)), 82 | add_example_item(user, (QUEEN, 'bartender')), 83 | add_example_item(user, (QUEEN, 'pypo')), 84 | add_example_item(user, tuple())] 85 | yield items 86 | for item in items: 87 | item.delete() 88 | 89 | @pytest.fixture 90 | def clear_index(): 91 | call_command('clear_index', interactive=False, verbosity=0) 92 | 93 | @pytest.fixture 94 | def get_mock(request, clear_index): 95 | patcher = patch('requests.get') 96 | get_mock = patcher.start() 97 | return_mock = Mock(headers={'content-type': 'text/html', 98 | 'content-length': 500}, 99 | encoding='utf-8') 100 | return_mock.iter_content.return_value = iter([b"example.com"]) 101 | get_mock.return_value = return_mock 102 | 103 | def fin(): 104 | patcher.stop() 105 | request.addfinalizer(fin) 106 | return get_mock 107 | 108 | 109 | TEST_INDEX = { 110 | 'default': { 111 | 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', 112 | 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index_test'), 113 | }, 114 | } 115 | 116 | @pytest.fixture 117 | def test_index(settings): 118 | settings.HAYSTACK_CONNECTIONS = TEST_INDEX 119 | call_command('clear_index', interactive=False, verbosity=0) 120 | haystack.connections.reload('default') 121 | 122 | @pytest.yield_fixture(scope='module') 123 | def simple_items(db, user): 124 | items = { 125 | 'item_fish': add_example_item(user, [QUEEN, 'fish', 'cookie']), 126 | 'item_box': add_example_item(user, [QUEEN, 'box']), 127 | 'filter': Item.objects.filter(owner_id=user.id), 128 | } 129 | yield items 130 | items['item_fish'].delete() 131 | items['item_box'].delete() 132 | 133 | -------------------------------------------------------------------------------- /deploy_tools/drone.io_comands.sh: -------------------------------------------------------------------------------- 1 | pip install -r requirements.txt --use-mirrors 2 | psql -c 'create database pypo;' -U postgres 3 | echo "DATABASES={'default':{'ENGINE':'django.db.backends.postgresql_psycopg2','NAME':'pypo','USER':'postgres','HOST':'127.0.0.1','PORT':5432}}" >> pypo/settings_local.py 4 | sudo start xvfb 5 | ./manage.py test 6 | -------------------------------------------------------------------------------- /deploy_tools/gunicorn-upstart.template.conf: -------------------------------------------------------------------------------- 1 | description "Gunicorn server for SITENAME" 2 | 3 | start on net-device-up 4 | stop on shutdown 5 | 6 | respawn 7 | 8 | chdir /home/USER/sites/SITENAME/source 9 | exec ../virtualenv/bin/gunicorn \ 10 | --bind unix:/tmp/SITENAME.socket \ 11 | superlists.wsgi:application -------------------------------------------------------------------------------- /deploy_tools/nginx.template.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name SITENAME; 4 | 5 | location /static { 6 | alias /home/USER/sites/SITENAME/static; 7 | } 8 | 9 | location / { 10 | proxy_set_header Host $host; 11 | proxy_pass http://unix:/tmp/SITENAME.socket; 12 | } 13 | } -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pypo.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pypo.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pypo" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pypo" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pypo documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Oct 30 15:02:51 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | 19 | 20 | os.environ['DJANGO_SETTINGS_MODULE'] = 'pypo.settings' 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | sys.path.insert(0, os.path.abspath('../')) 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.intersphinx', 38 | 'sphinx.ext.coverage', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | #source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'pypo' 56 | copyright = u'2013, Jens Kadenbach' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '0.1' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '0.1' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | #keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'default' 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | #html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | #html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | #html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | #html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | #html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | #html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | html_static_path = ['_static'] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | #html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | #html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | #html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | #html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | #html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | #html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | #html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | #html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | #html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | #html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | #html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | #html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | #html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = 'pypodoc' 189 | 190 | 191 | # -- Options for LaTeX output --------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | #'papersize': 'letterpaper', 196 | 197 | # The font size ('10pt', '11pt' or '12pt'). 198 | #'pointsize': '10pt', 199 | 200 | # Additional stuff for the LaTeX preamble. 201 | #'preamble': '', 202 | } 203 | 204 | # Grouping the document tree into LaTeX files. List of tuples 205 | # (source start file, target name, title, 206 | # author, documentclass [howto, manual, or own class]). 207 | latex_documents = [ 208 | ('index', 'pypo.tex', u'pypo Documentation', 209 | u'Jens Kadenbach', 'manual'), 210 | ] 211 | 212 | # The name of an image file (relative to this directory) to place at the top of 213 | # the title page. 214 | #latex_logo = None 215 | 216 | # For "manual" documents, if this is true, then toplevel headings are parts, 217 | # not chapters. 218 | #latex_use_parts = False 219 | 220 | # If true, show page references after internal links. 221 | #latex_show_pagerefs = False 222 | 223 | # If true, show URL addresses after external links. 224 | #latex_show_urls = False 225 | 226 | # Documents to append as an appendix to all manuals. 227 | #latex_appendices = [] 228 | 229 | # If false, no module index is generated. 230 | #latex_domain_indices = True 231 | 232 | 233 | # -- Options for manual page output --------------------------------------- 234 | 235 | # One entry per manual page. List of tuples 236 | # (source start file, name, description, authors, manual section). 237 | man_pages = [ 238 | ('index', 'pypo', u'pypo Documentation', 239 | [u'Jens Kadenbach'], 1) 240 | ] 241 | 242 | # If true, show URL addresses after external links. 243 | #man_show_urls = False 244 | 245 | 246 | # -- Options for Texinfo output ------------------------------------------- 247 | 248 | # Grouping the document tree into Texinfo files. List of tuples 249 | # (source start file, target name, title, author, 250 | # dir menu entry, description, category) 251 | texinfo_documents = [ 252 | ('index', 'pypo', u'pypo Documentation', 253 | u'Jens Kadenbach', 'pypo', 'One line description of project.', 254 | 'Miscellaneous'), 255 | ] 256 | 257 | # Documents to append as an appendix to all manuals. 258 | #texinfo_appendices = [] 259 | 260 | # If false, no module index is generated. 261 | #texinfo_domain_indices = True 262 | 263 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 264 | #texinfo_show_urls = 'footnote' 265 | 266 | # If true, do not generate a @detailmenu in the "Top" node's menu. 267 | #texinfo_no_detailmenu = False 268 | 269 | 270 | # Example configuration for intersphinx: refer to the Python standard library. 271 | intersphinx_mapping = {'http://docs.python.org/': None} 272 | -------------------------------------------------------------------------------- /doc/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | You can overwrite all default settings either directly in pypo/settings.py or you create the file pypo/settings_local.py 5 | which is imported in the pypo/settings.py and therefore can overwrite all settings. 6 | 7 | ``SECRET_KEY`` 8 | ============== 9 | Like in all django application, you have to set a unique secret key. `Django SECRET_KEY documentation`_ 10 | 11 | ``DEBUG, TEMPLATE_DEBUG, CRISPY_FAIL_SILENTLY`` 12 | =========================================== 13 | To enable or disable debugging (crispy is a form component) 14 | 15 | ``ALLOWED_HOSTS`` 16 | ================== 17 | A list of hostnames. `Django ALLOWED_HOSTS documentation`_ 18 | 19 | ``ADMINS`` 20 | ========== 21 | A list of tuples ("name", "email") of admins 22 | 23 | ``STATIC_ROOT`` 24 | =============== 25 | Absolute path where your static file are collected to when you call ``./manage.py collectstatic`` 26 | 27 | ``STATIC_URL`` 28 | ============== 29 | Url where those files are available 30 | 31 | ``DATABASES`` 32 | ============= 33 | You database config. Pypo is tested with PostgreSQL, but any django supported DB should be 34 | fine. `Django DATABASES documentation`_ 35 | 36 | ``HAYSTACK_CONNECTIONS`` 37 | ======================= 38 | If you want to use something else than Whoosh (a pure python search index), you can configure 39 | the search backend here. `Django Haystack documentation`_ 40 | It is recommended to switch to Elasticsearch for larger datasets: 41 | HAYSTACK_CONNECTIONS = { 42 | 'default': { 43 | 'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine', 44 | 'URL': 'http://127.0.0.1:9200/', 45 | 'INDEX_NAME': 'pypo', 46 | }, 47 | } 48 | 49 | 50 | 51 | .. _Django SECRET_KEY documentation: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY 52 | .. _Django ALLOWED_HOSTS documentation: https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts 53 | .. _Django DATABASES documentation: https://docs.djangoproject.com/en/dev/ref/settings/#databases 54 | .. _Django Haystack documentation: http://django-haystack.readthedocs.org/en/latest/settings.html#haystack-connections 55 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. pypo documentation master file, created by 2 | sphinx-quickstart on Wed Oct 30 15:02:51 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to pypo's documentation! 7 | ================================ 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | configuration 13 | readme_module 14 | 15 | .. include:: ../README.rst 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`search` 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /doc/readme_module.rst: -------------------------------------------------------------------------------- 1 | Readme Module 2 | ============= 3 | 4 | Everything except for configuration. 5 | 6 | Models 7 | ------ 8 | 9 | .. automodule:: readme.models 10 | :members: 11 | 12 | Scrapers 13 | -------- 14 | 15 | .. automodule:: readme.scrapers 16 | :members: -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from fabric.api import run, local, env 3 | from fabric.contrib.files import exists 4 | 5 | SITES_FOLDER = '/home/dax/sites' 6 | 7 | REPO_URL = 'https://github.com/audax/pypo.git' 8 | 9 | def deploy(): 10 | _create_directory_structure_if_necessary(env.host) 11 | source_folder = path.join(SITES_FOLDER, env.host, 'source') 12 | _get_latest_source(source_folder) 13 | _update_virtualenv(source_folder) 14 | _update_static_files(source_folder) 15 | _update_database(source_folder) 16 | 17 | def _create_directory_structure_if_necessary(site_name): 18 | base_folder = path.join(SITES_FOLDER, site_name) 19 | run('mkdir -p %s' % (base_folder)) #12 20 | for subfolder in ('database', 'static', 'virtualenv', 'source'): 21 | run('mkdir -p %s/%s' % (base_folder, subfolder)) 22 | 23 | def _update_virtualenv(source_folder): 24 | virtualenv_folder = path.join(source_folder, '../virtualenv') 25 | if not exists(path.join(virtualenv_folder, 'bin', 'pip')): 26 | run('virtualenv --python=python3.3 %s' % (virtualenv_folder,)) 27 | run('%s/bin/pip install -r %s/requirements.txt' % ( 28 | virtualenv_folder, source_folder 29 | )) 30 | 31 | 32 | def _update_static_files(source_folder): 33 | run('cd %s && bower install' % source_folder) 34 | run('cd %s && lessc readme/static/css/readme.less readme/static/css/readme.css' % source_folder) 35 | run('cd %s && ../virtualenv/bin/python3 manage.py collectstatic --noinput -i test' % source_folder) 36 | 37 | 38 | def _update_database(source_folder): 39 | run('cd %s && ../virtualenv/bin/python3 manage.py syncdb --noinput' % ( 40 | source_folder, 41 | )) 42 | run('cd %s && ../virtualenv/bin/python3 manage.py migrate --noinput' % ( 43 | source_folder, 44 | )) 45 | 46 | def _get_latest_source(source_folder): 47 | if exists(path.join(source_folder, '.git')): 48 | run('cd %s && git fetch' % (source_folder,)) 49 | else: 50 | run('git clone %s %s' % (REPO_URL, source_folder)) 51 | current_commit = local("git log -n 1 --format=%H", capture=True) 52 | run('cd %s && git reset --hard %s' % (source_folder, current_commit)) 53 | 54 | def reload_wsgi(): 55 | run("sudo service {}.gunicorn restart".format(env.host)) 56 | 57 | def update_index(): 58 | source_folder = path.join(SITES_FOLDER, env.host, 'source') 59 | run('cd %s && ../virtualenv/bin/python3 manage.py update_index' % ( 60 | source_folder, 61 | )) 62 | -------------------------------------------------------------------------------- /functional_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audax/pypo/58ce116bf76d50faa34977a80075d488736ffcc8/functional_tests/__init__.py -------------------------------------------------------------------------------- /functional_tests/fixtures/items.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audax/pypo/58ce116bf76d50faa34977a80075d488736ffcc8/functional_tests/fixtures/items.json -------------------------------------------------------------------------------- /functional_tests/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "dev", 7 | "first_name": "", 8 | "last_name": "", 9 | "is_active": true, 10 | "is_superuser": true, 11 | "is_staff": true, 12 | "last_login": "2013-08-15T18:13:57Z", 13 | "groups": [], 14 | "user_permissions": [], 15 | "password": "pbkdf2_sha256$10000$YfbMFc1S8DAt$6+xBnuJ0OhC6cjs3K6z+a5w3PSY/RQElgsLhHWGvcLg=", 16 | "email": "dev@localhost", 17 | "date_joined": "2013-08-15T18:01:53Z" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /functional_tests/models.py: -------------------------------------------------------------------------------- 1 | __author__ = 'dax' 2 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pypo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pypo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audax/pypo/58ce116bf76d50faa34977a80075d488736ffcc8/pypo/__init__.py -------------------------------------------------------------------------------- /pypo/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from os import path 4 | 5 | PROJECT_ROOT = path.abspath(path.join(path.dirname(__file__), '..')) 6 | 7 | # Django settings for pypo project. 8 | 9 | DEBUG = True 10 | TEMPLATE_DEBUG = DEBUG 11 | 12 | ADMINS = ( 13 | # ('Your Name', 'your_email@example.com'), 14 | ) 15 | 16 | MANAGERS = ADMINS 17 | 18 | DATABASES = { 19 | 'default': { 20 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 21 | 'NAME': 'pypo', # Or path to database file if using sqlite3. 22 | # The following settings are not used with sqlite3: 23 | 'USER': 'postgres', 24 | 'PASSWORD': '', 25 | 'HOST': '', # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. 26 | 'PORT': '', # Set to empty string for default. 27 | } 28 | } 29 | 30 | CACHES = { 31 | 'default': { 32 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 33 | 'LOCATION': 'unique-snowflake' 34 | } 35 | } 36 | 37 | # Hosts/domain names that are valid for this site; required if DEBUG is False 38 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts 39 | ALLOWED_HOSTS = [] 40 | 41 | # Local time zone for this installation. Choices can be found here: 42 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 43 | # although not all choices may be available on all operating systems. 44 | # In a Windows environment this must be set to your system time zone. 45 | TIME_ZONE = 'Europe/Berlin' 46 | 47 | # Language code for this installation. All choices can be found here: 48 | # http://www.i18nguy.com/unicode/language-identifiers.html 49 | LANGUAGE_CODE = 'en-us' 50 | 51 | SITE_ID = 1 52 | 53 | # If you set this to False, Django will make some optimizations so as not 54 | # to load the internationalization machinery. 55 | USE_I18N = True 56 | 57 | # If you set this to False, Django will not format dates, numbers and 58 | # calendars according to the current locale. 59 | USE_L10N = True 60 | 61 | # If you set this to False, Django will not use timezone-aware datetimes. 62 | USE_TZ = True 63 | 64 | # Absolute filesystem path to the directory that will hold user-uploaded files. 65 | # Example: "/var/www/example.com/media/" 66 | MEDIA_ROOT = '' 67 | 68 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 69 | # trailing slash. 70 | # Examples: "http://example.com/media/", "http://media.example.com/" 71 | MEDIA_URL = '' 72 | 73 | # Absolute path to the directory static files should be collected to. 74 | # Don't put anything in this directory yourself; store your static files 75 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 76 | # Example: "/var/www/example.com/static/" 77 | STATIC_ROOT = path.join(PROJECT_ROOT, '../static') 78 | 79 | # URL prefix for static files. 80 | # Example: "http://example.com/static/", "http://static.example.com/" 81 | STATIC_URL = '/static/' 82 | 83 | BOWER = path.join(PROJECT_ROOT, 'bower_components') 84 | 85 | 86 | # Additional locations of static files 87 | STATICFILES_DIRS = ( 88 | BOWER, 89 | ) 90 | 91 | PIPELINE_JS = { 92 | 'components': { 93 | 'source_filenames': ( 94 | 'jquery/dist/jquery.js', 95 | 'jquery-ui/ui/jquery-ui.js', 96 | 'bootstrap/dist/js/bootstrap.js', 97 | 'PopConfirm/jquery.popconfirm.js', 98 | 'select2/select2.js', 99 | 'x-editable/dist/bootstrap3-editable/js/bootstrap-editable.js', 100 | 'js/readme.js', 101 | # you can choose to be specific to reduce your payload 102 | ), 103 | 'output_filename': 'js/components.js', 104 | }, 105 | 'testing': { 106 | 'source_filenames': ( 107 | 'qunit/qunit/qunit.js', 108 | 'sinon/lib/sinon.js', 109 | ), 110 | 'output_filename': 'js/testing.js', 111 | }, 112 | } 113 | 114 | # Disable yuglify until it works with jquery again 115 | # https://github.com/yui/yuglify/issues/19 116 | PIPELINE_JS_COMPRESSOR = None 117 | 118 | PIPELINE_CSS = { 119 | 'all': { 120 | 'source_filenames': ( 121 | 'select2/select2.css', 122 | 'bootstrap-tokenfield/dist/css/bootstrap-tokenfield.css', 123 | 'jquery-ui/themes/base/jquery-ui.css', 124 | 'fontawesome/css/font-awesome.css', 125 | 'select2-bootstrap-css/select2-bootstrap.css', 126 | 'css/readme.css', 127 | ), 128 | 'output_filename': 'css/all.css', 129 | }, 130 | 'testing': { 131 | 'source_filenames': ( 132 | 'qunit/qunit/qunit.css', 133 | ), 134 | 'output_filename': 'js/testing.js', 135 | }, 136 | } 137 | 138 | # List of finder classes that know how to find static files in 139 | # various locations. 140 | STATICFILES_FINDERS = ( 141 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 142 | 'django.contrib.staticfiles.finders.FileSystemFinder', 143 | 'pipeline.finders.PipelineFinder', 144 | ) 145 | 146 | STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' 147 | 148 | # Make this unique, and don't share it with anybody. 149 | SECRET_KEY = '42' 150 | 151 | # List of callables that know how to import templates from various sources. 152 | TEMPLATE_LOADERS = ( 153 | ('django.template.loaders.cached.Loader', ( 154 | 'django.template.loaders.filesystem.Loader', 155 | 'django.template.loaders.app_directories.Loader', 156 | )), 157 | ) 158 | 159 | TEMPLATE_CONTEXT_PROCESSORS = ( 160 | "django.contrib.auth.context_processors.auth", 161 | "django.core.context_processors.debug", 162 | "django.core.context_processors.i18n", 163 | "django.core.context_processors.media", 164 | "django.core.context_processors.static", 165 | "django.core.context_processors.tz", 166 | "django.contrib.messages.context_processors.messages", 167 | "settings_context_processor.context_processors.settings", 168 | "django.core.context_processors.request", 169 | ) 170 | 171 | 172 | MIDDLEWARE_CLASSES = ( 173 | 'django.middleware.common.CommonMiddleware', 174 | 'django.contrib.sessions.middleware.SessionMiddleware', 175 | 'django.middleware.csrf.CsrfViewMiddleware', 176 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 177 | 'django.contrib.messages.middleware.MessageMiddleware', 178 | # Uncomment the next line for simple clickjacking protection: 179 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 180 | ) 181 | 182 | SESSION_COOKIE_NAME = 'sessionid' 183 | AUTHENTICATION_BACKENDS = ('django.contrib.auth.backends.ModelBackend',) 184 | 185 | ROOT_URLCONF = 'pypo.urls' 186 | 187 | # Python dotted path to the WSGI application used by Django's runserver. 188 | WSGI_APPLICATION = 'pypo.wsgi.application' 189 | 190 | TEMPLATE_DIRS = ( 191 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 192 | # Always use forward slashes, even on Windows. 193 | # Don't forget to use absolute paths, not relative paths. 194 | ) 195 | 196 | LOGIN_REDIRECT_URL = '/' 197 | 198 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 199 | 200 | INSTALLED_APPS = ( 201 | 'django.contrib.auth', 202 | 'django.contrib.contenttypes', 203 | 'django.contrib.sessions', 204 | 'django.contrib.sites', 205 | 'django.contrib.messages', 206 | 'django.contrib.staticfiles', 207 | 'pipeline', 208 | # Uncomment the next line to enable admin documentation: 209 | 'django.contrib.admindocs', 210 | 'readme', 211 | 'crispy_forms', 212 | 'haystack', 213 | 'taggit', 214 | 'rest_framework', 215 | 'django_admin_bootstrapped.bootstrap3', 216 | 'django_admin_bootstrapped', 217 | 'django.contrib.admin', 218 | 'django.contrib.humanize', 219 | 'settings_context_processor', 220 | 'sitegate', 221 | 'functional_tests', 222 | ) 223 | 224 | # A sample logging configuration. The only tangible logging 225 | # performed by this configuration is to send an email to 226 | # the site admins on every HTTP 500 error when DEBUG=False. 227 | # See http://docs.djangoproject.com/en/dev/topics/logging for 228 | # more details on how to customize your logging configuration. 229 | LOGGING = { 230 | 'version': 1, 231 | 'disable_existing_loggers': False, 232 | 'filters': { 233 | 'require_debug_false': { 234 | '()': 'django.utils.log.RequireDebugFalse' 235 | } 236 | }, 237 | 'handlers': { 238 | 'mail_admins': { 239 | 'level': 'ERROR', 240 | 'filters': ['require_debug_false'], 241 | 'class': 'django.utils.log.AdminEmailHandler' 242 | }, 243 | 'console': { 244 | 'level': 'DEBUG', 245 | 'class': 'logging.StreamHandler' 246 | } 247 | }, 248 | 'loggers': { 249 | 'django.request': { 250 | 'handlers': ['mail_admins'], 251 | 'level': 'ERROR', 252 | 'propagate': True, 253 | }, 254 | 'readme.request': { 255 | 'handlers': ['mail_admins'], 256 | 'level': 'INFO', 257 | 'propagate': True, 258 | }, 259 | } 260 | } 261 | 262 | CRISPY_FAIL_SILENTLY = not DEBUG 263 | CRISPY_TEMPLATE_PACK = 'bootstrap3' 264 | 265 | HAYSTACK_CONNECTIONS = { 266 | 'default': { 267 | 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', 268 | 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), 269 | }, 270 | } 271 | 272 | HAYSTACK_SIGNAL_PROCESSOR = 'readme.signals.ItemOnlySignalProcessor' 273 | 274 | REST_FRAMEWORK = { 275 | # Use hyperlinked styles by default. 276 | # Only used if the `serializer_class` attribute is not set on a view. 277 | 'DEFAULT_MODEL_SERIALIZER_CLASS': 278 | 'rest_framework.serializers.HyperlinkedModelSerializer', 279 | 280 | 'DEFAULT_PERMISSION_CLASSES': ( 281 | 'rest_framework.permissions.IsAuthenticated', 282 | ), 283 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 284 | 'rest_framework.authentication.BasicAuthentication', 285 | 'rest_framework.authentication.SessionAuthentication', 286 | ) 287 | } 288 | 289 | TEMPLATE_VISIBLE_SETTINGS = ('PYPO_DEFAULT_THEME',) 290 | 291 | # 10MB 292 | PYPO_MAX_CONTENT_LENGTH = int(1.049e+7) 293 | 294 | PYPO_DEFAULT_THEME = 'slate' 295 | 296 | PYPO_THEMES = ( 297 | ('amelia', 'Amelia'), 298 | ('cerulean', 'Cerulean'), 299 | ('cosmo', 'Cosmo'), 300 | ('cyborg', 'Cyborg'), 301 | ('flatly', 'Flatly'), 302 | ('journal', 'Journal'), 303 | ('readable', 'Readable'), 304 | ('simplex', 'Simplex'), 305 | ('slate', 'Slate'), 306 | ('spacelab', 'SpaceLab'), 307 | ('united', 'United'), 308 | ('superhero', 'Superhero'), 309 | ('lumen', 'Lumen'), 310 | ) 311 | 312 | try: 313 | from .settings_local import * 314 | except ImportError: 315 | pass 316 | -------------------------------------------------------------------------------- /pypo/settings_local.py.template: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'foobarudiarten' 2 | -------------------------------------------------------------------------------- /pypo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.contrib import admin 3 | 4 | 5 | admin.autodiscover() 6 | 7 | readme_patterns = patterns('readme.views', 8 | url(r'^$', 'index', name='index'), 9 | url(r'^tags/(?P.*)$', 'tags', name='tags'), 10 | url(r'^add/$', 'add', name='item_add'), 11 | url(r'^update/(?P\d+)/$', 'update', name='item_update'), 12 | url(r'^view/(?P\d+)/$', 'view', name='item_view'), 13 | url(r'^search/', 'search', name='haystack_search'), 14 | url(r'^invite/$', 'invite', name='invite'), 15 | url(r'^profile/$', 'profile', name='profile'), 16 | url(r'^test/(?P\w+)$', 'test', name='test_view'), 17 | ) 18 | 19 | 20 | urlpatterns = patterns('', 21 | url(r'^accounts/', include('readme.account_urls')), 22 | url(r'^api/', include('readme.api_urls')), 23 | url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), 24 | 25 | url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 26 | url(r'^admin/', include(admin.site.urls)), 27 | ) + readme_patterns 28 | -------------------------------------------------------------------------------- /pypo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pypo project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "pypo.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pypo.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = node_modules bower_components whoosh_* doc 3 | DJANGO_SETTINGS_MODULE = pypo.settings 4 | -------------------------------------------------------------------------------- /readme/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audax/pypo/58ce116bf76d50faa34977a80075d488736ffcc8/readme/__init__.py -------------------------------------------------------------------------------- /readme/account_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | from django.core.urlresolvers import reverse_lazy 3 | 4 | urlpatterns = patterns('', 5 | url(r'^login/$', 'readme.views.entrance', name='login'), 6 | url(r'^logout/$', 'django.contrib.auth.views.logout', {'next_page': reverse_lazy('index')}, name='logout'), 7 | url(r'^password_change/$', 'django.contrib.auth.views.password_change', 8 | {'post_change_redirect': reverse_lazy('index')}, name='password_change'), 9 | url(r'^password_change/done/$', 'django.contrib.auth.views.password_change_done', 10 | name='password_change_done'), 11 | url(r'^password_reset/$', 'django.contrib.auth.views.password_reset', name='password_reset'), 12 | url(r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', 13 | name='password_reset_done'), 14 | url(r'^reset/(?P[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 15 | 'django.contrib.auth.views.password_reset_confirm', 16 | name='password_reset_confirm'), 17 | url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', 18 | name='password_reset_complete'), 19 | ) 20 | -------------------------------------------------------------------------------- /readme/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as OriginalUserAdmin 3 | from django.contrib.auth.models import User 4 | from .models import UserProfile 5 | 6 | 7 | class UserProfileInline(admin.StackedInline): 8 | model = UserProfile 9 | can_delete = False 10 | 11 | 12 | class UserAdmin(OriginalUserAdmin): 13 | inlines = [UserProfileInline] 14 | 15 | try: 16 | admin.site.unregister(User) 17 | finally: 18 | admin.site.register(User, UserAdmin) 19 | 20 | -------------------------------------------------------------------------------- /readme/api.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from .serializers import ItemSerializer 3 | from .models import Item 4 | 5 | 6 | class ItemViewSet(viewsets.ModelViewSet): 7 | """ 8 | API endpoint that allows Items to be viewed or edited. 9 | """ 10 | serializer_class = ItemSerializer 11 | model = Item 12 | 13 | def get_queryset(self): 14 | """ 15 | Filter Items by the current user 16 | """ 17 | return Item.objects.filter(owner=self.request.user).order_by('-created').prefetch_related('tags') 18 | 19 | def perform_create(self, serializer): 20 | """ 21 | Pass the current user to the serializer 22 | """ 23 | serializer.save(owner=self.request.user) 24 | 25 | 26 | -------------------------------------------------------------------------------- /readme/api_urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from readme.api import ItemViewSet 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r'items', ItemViewSet) 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /readme/download.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import requests 3 | 4 | 5 | class DownloadException(Exception): 6 | 7 | def __init__(self, message, *args, parent=None, **kwargs): 8 | self.parent = parent 9 | self.message = message 10 | super(DownloadException, self).__init__(*args, **kwargs) 11 | 12 | DownloadedContent = namedtuple('DownloadedContent', ('text', 'content', 'content_type')) 13 | 14 | def download(url, max_content_length=1000): 15 | """ 16 | Download content with an upper bound for the content_length 17 | :param url: Url 18 | :param max_content_length: length in bytes 19 | :return: DownloadedContent 20 | :raise DownloadException: for all errors 21 | """ 22 | try: 23 | req = requests.get(url, stream=True, verify=False) 24 | except requests.RequestException as e: 25 | raise DownloadException("Request failed", parent=e) 26 | 27 | try: 28 | content_length = int(req.headers.get('content-length', 0)) 29 | except ValueError as e: 30 | # no valid content length set 31 | raise DownloadException("Could not convert: content-length = {}".format( 32 | req.headers.get('content-length')), parent=e) 33 | else: 34 | # if content is too long, abort. 35 | if content_length > max_content_length: 36 | raise DownloadException('Aborting: content-length {} is larger than max content length {}'.format( 37 | content_length, max_content_length)) 38 | try: 39 | # In case content_length lied to us 40 | content = next(req.iter_content(max_content_length)) 41 | except StopIteration: 42 | # And in case there is no content 43 | content = None 44 | 45 | text = None 46 | # only decode text requests 47 | content_type = req.headers.get('content-type', '') 48 | 49 | if content_type.startswith('text/') and content is not None: 50 | encoding = req.encoding 51 | try: 52 | text = content.decode(encoding, errors='ignore') 53 | except LookupError: 54 | pass 55 | else: 56 | content_encodings = requests.utils.get_encodings_from_content(text) 57 | # if we detect more than the fallback encoding, use that one 58 | if content_encodings != ['ISO-8859-1']: 59 | try: 60 | text = content.decode(content_encodings[0], errors='ignore') 61 | except LookupError: 62 | pass 63 | 64 | return DownloadedContent(content=content, text=text, content_type=content_type) 65 | 66 | -------------------------------------------------------------------------------- /readme/forms.py: -------------------------------------------------------------------------------- 1 | from crispy_forms import layout 2 | from django import forms 3 | from django.conf import settings 4 | from django.utils.encoding import force_text 5 | from haystack.forms import FacetedSearchForm 6 | import six 7 | from taggit.forms import TagWidget 8 | from .models import Item, UserProfile 9 | from crispy_forms.helper import FormHelper 10 | 11 | 12 | def edit_string_for_tags(tags): 13 | return ', '.join(sorted(tag.name for tag in tags)) 14 | 15 | def parse_tags(tagstring): 16 | if not tagstring: 17 | return [] 18 | 19 | tagstring = force_text(tagstring) 20 | 21 | words = [word.strip() for word in tagstring.split(',')] 22 | return sorted(set(words)) 23 | 24 | class QuotelessTagWidget(TagWidget): 25 | def render(self, name, value, attrs=None): 26 | if value is not None and not isinstance(value, six.string_types): 27 | value = edit_string_for_tags([o.tag for o in value.select_related("tag")]) 28 | return super(QuotelessTagWidget, self).render(name, value, attrs) 29 | 30 | class TagField(forms.CharField): 31 | widget = TagWidget 32 | 33 | def clean(self, value): 34 | value = super(TagField, self).clean(value) 35 | try: 36 | return parse_tags(value) 37 | except ValueError: 38 | raise forms.ValidationError("Please provide a comma-separated list of tags.") 39 | 40 | class CreateItemForm(forms.ModelForm): 41 | 42 | tags = TagField(required=False) 43 | 44 | def __init__(self, *args, **kwargs): 45 | h = FormHelper() 46 | h.form_id = 'create-item-form' 47 | h.form_method = 'post' 48 | h.form_class = 'form-horizontal' 49 | h.label_class = 'col-lg-2' 50 | h.field_class = 'col-lg-8' 51 | h.help_text_inline = True 52 | h.error_text_inline = True 53 | h.html5_required = True 54 | 55 | h.add_input(layout.Submit('submit', 'Submit')) 56 | self.helper = h 57 | super(CreateItemForm, self).__init__(*args, **kwargs) 58 | 59 | def clean(self): 60 | cleaned_data = self.cleaned_data 61 | if cleaned_data['tags']: 62 | cleaned_data['tags'] = [tag[:99] for tag in cleaned_data['tags']] 63 | return cleaned_data 64 | 65 | class Meta: 66 | model = Item 67 | fields = ('url', 'tags',) 68 | widgets = { 69 | 'tags': QuotelessTagWidget() 70 | } 71 | 72 | 73 | class UpdateItemForm(CreateItemForm): 74 | 75 | class Meta: 76 | model = Item 77 | fields = ('tags',) 78 | widgets = { 79 | 'tags': QuotelessTagWidget() 80 | } 81 | 82 | 83 | class UserProfileForm(forms.ModelForm): 84 | theme = forms.ChoiceField( 85 | label='Theme', 86 | help_text='Choose a color theme', 87 | choices=settings.PYPO_THEMES) 88 | 89 | items_per_page = forms.IntegerField( 90 | label='Page size', 91 | help_text='Select how many items should be shown on a page', 92 | min_value=1, 93 | max_value=100) 94 | 95 | excluded_tags = TagField( 96 | label='Excluded tags', 97 | help_text='Items with these tags will not be shown', 98 | required=False) 99 | 100 | def __init__(self, *args, **kwargs): 101 | h = FormHelper() 102 | h.form_id = 'user-profile-form' 103 | h.form_class = 'form-horizontal' 104 | h.label_class = 'col-lg-2' 105 | h.field_class = 'col-lg-4' 106 | h.layout = layout.Layout( 107 | 'theme', 108 | 'new_window', 109 | 'items_per_page', 110 | 'excluded_tags', 111 | 'show_excluded', 112 | layout.Div( 113 | layout.Div( 114 | layout.Submit('Save', value='Save', css_class='btn-default'), 115 | css_class='col-lg-offset-2 col-lg-4' 116 | ), 117 | css_class='form-group', 118 | ) 119 | ) 120 | h.help_text_inline = True 121 | h.error_text_inline = True 122 | h.html5_required = True 123 | 124 | self.helper = h 125 | super(UserProfileForm, self).__init__(*args, **kwargs) 126 | 127 | class Meta: 128 | model = UserProfile 129 | fields = ['theme', 'new_window', 'items_per_page', 'excluded_tags', 'show_excluded'] 130 | 131 | 132 | class SearchForm(FacetedSearchForm): 133 | 134 | sort = forms.Select() 135 | 136 | def __init__(self, *args, **kwargs): 137 | h = FormHelper() 138 | h.form_id = 'item-search-form' 139 | h.form_method = 'GET' 140 | h.form_class = 'form-horizontal' 141 | h.label_class = 'col-lg-2' 142 | h.field_class = 'col-lg-8' 143 | h.layout = layout.Layout( 144 | 'q', 145 | layout.Div( 146 | layout.Div( 147 | layout.Button('Search', value='Search', css_class='btn-primary'), 148 | layout.Div( 149 | layout.HTML(''), 153 | layout.HTML(''), 157 | css_class="pull-right"), 158 | css_class='col-lg-offset-2 col-lg-8' 159 | ), 160 | css_class='form-group', 161 | ) 162 | ) 163 | h.help_text_inline = True 164 | h.error_text_inline = True 165 | h.html5_required = True 166 | 167 | self.helper = h 168 | super(SearchForm, self).__init__(*args, **kwargs) 169 | -------------------------------------------------------------------------------- /readme/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | pass 12 | 13 | def backwards(self, orm): 14 | pass 15 | 16 | models = { 17 | 18 | } 19 | 20 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0002_auto__add_item.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'Item' 12 | db.create_table(u'readme_item', ( 13 | (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('url', self.gf('django.db.models.fields.URLField')(max_length=200)), 15 | ('title', self.gf('django.db.models.fields.TextField')()), 16 | ('slug', self.gf('django.db.models.fields.SlugField')(max_length=30)), 17 | ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 18 | )) 19 | db.send_create_signal(u'readme', ['Item']) 20 | 21 | 22 | def backwards(self, orm): 23 | # Deleting model 'Item' 24 | db.delete_table(u'readme_item') 25 | 26 | 27 | models = { 28 | u'readme.item': { 29 | 'Meta': {'object_name': 'Item'}, 30 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 31 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 32 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), 33 | 'title': ('django.db.models.fields.TextField', [], {}), 34 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) 35 | } 36 | } 37 | 38 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0003_auto__add_field_item_owner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Item.owner' 12 | db.add_column(u'readme_item', 'owner', 13 | self.gf('django.db.models.fields.related.ForeignKey')(default=0, to=orm['auth.User']), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'Item.owner' 19 | db.delete_column(u'readme_item', 'owner_id') 20 | 21 | 22 | models = { 23 | u'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 28 | }, 29 | u'auth.permission': { 30 | 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 33 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | u'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 42 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | u'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | u'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), 64 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), 65 | 'title': ('django.db.models.fields.TextField', [], {}), 66 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) 67 | } 68 | } 69 | 70 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0004_auto__del_field_item_slug.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Deleting field 'Item.slug' 12 | db.delete_column(u'readme_item', 'slug') 13 | 14 | 15 | def backwards(self, orm): 16 | 17 | # User chose to not deal with backwards NULL issues for 'Item.slug' 18 | raise RuntimeError("Cannot reverse this migration. 'Item.slug' and its values cannot be restored.") 19 | 20 | # The following code is provided here to aid in writing a correct migration # Adding field 'Item.slug' 21 | db.add_column(u'readme_item', 'slug', 22 | self.gf('django.db.models.fields.SlugField')(max_length=30), 23 | keep_default=False) 24 | 25 | 26 | models = { 27 | u'auth.group': { 28 | 'Meta': {'object_name': 'Group'}, 29 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 30 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 31 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 32 | }, 33 | u'auth.permission': { 34 | 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, 35 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 36 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 37 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 38 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 39 | }, 40 | u'auth.user': { 41 | 'Meta': {'object_name': 'User'}, 42 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 43 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 44 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 45 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 46 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 47 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 48 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 49 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 50 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 51 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 52 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 53 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 54 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 55 | }, 56 | u'contenttypes.contenttype': { 57 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 58 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 59 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 60 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 61 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 62 | }, 63 | u'readme.item': { 64 | 'Meta': {'object_name': 'Item'}, 65 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 66 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 67 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), 68 | 'title': ('django.db.models.fields.TextField', [], {}), 69 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) 70 | } 71 | } 72 | 73 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0005_auto__add_field_item_readable_article.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Item.readable_article' 12 | db.add_column(u'readme_item', 'readable_article', 13 | self.gf('django.db.models.fields.TextField')(null=True), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'Item.readable_article' 19 | db.delete_column(u'readme_item', 'readable_article') 20 | 21 | 22 | models = { 23 | u'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 28 | }, 29 | u'auth.permission': { 30 | 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 33 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | u'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 42 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | u'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | u'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), 64 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 65 | 'title': ('django.db.models.fields.TextField', [], {}), 66 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) 67 | } 68 | } 69 | 70 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0006_auto__chg_field_item_url.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Changing field 'Item.url' 13 | db.alter_column(u'readme_item', 'url', self.gf('django.db.models.fields.URLField')(max_length=2000)) 14 | 15 | def backwards(self, orm): 16 | 17 | # Changing field 'Item.url' 18 | db.alter_column(u'readme_item', 'url', self.gf('django.db.models.fields.URLField')(max_length=200)) 19 | 20 | models = { 21 | u'auth.group': { 22 | 'Meta': {'object_name': 'Group'}, 23 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 24 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 25 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 26 | }, 27 | u'auth.permission': { 28 | 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, 29 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 30 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 31 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 32 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 33 | }, 34 | u'auth.user': { 35 | 'Meta': {'object_name': 'User'}, 36 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 37 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 38 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 39 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 40 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 41 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 42 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 43 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 44 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 45 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 46 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 47 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 48 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 49 | }, 50 | u'contenttypes.contenttype': { 51 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 52 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 53 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 54 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 56 | }, 57 | u'readme.item': { 58 | 'Meta': {'object_name': 'Item'}, 59 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 60 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 61 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), 62 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 63 | 'title': ('django.db.models.fields.TextField', [], {}), 64 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 65 | }, 66 | u'taggit.tag': { 67 | 'Meta': {'object_name': 'Tag'}, 68 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 69 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 70 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 71 | }, 72 | u'taggit.taggeditem': { 73 | 'Meta': {'object_name': 'TaggedItem'}, 74 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_tagged_items'", 'to': u"orm['contenttypes.ContentType']"}), 75 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 76 | 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), 77 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_items'", 'to': u"orm['taggit.Tag']"}) 78 | } 79 | } 80 | 81 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0007_auto__add_itemtag__add_taggeditem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'ItemTag' 12 | db.create_table('readme_itemtag', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('name', self.gf('django.db.models.fields.CharField')(max_length=100, unique=True)), 15 | ('slug', self.gf('django.db.models.fields.SlugField')(max_length=100, unique=True)), 16 | )) 17 | db.send_create_signal('readme', ['ItemTag']) 18 | 19 | # Adding model 'TaggedItem' 20 | db.create_table('readme_taggeditem', ( 21 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 22 | ('tag', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['readme.ItemTag'])), 23 | ('content_object', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['readme.Item'])), 24 | )) 25 | db.send_create_signal('readme', ['TaggedItem']) 26 | 27 | 28 | def backwards(self, orm): 29 | # Deleting model 'ItemTag' 30 | db.delete_table('readme_itemtag') 31 | 32 | # Deleting model 'TaggedItem' 33 | db.delete_table('readme_taggeditem') 34 | 35 | 36 | models = { 37 | 'auth.group': { 38 | 'Meta': {'object_name': 'Group'}, 39 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 40 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), 41 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'blank': 'True', 'to': "orm['auth.Permission']"}) 42 | }, 43 | 'auth.permission': { 44 | 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission'}, 45 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 46 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 47 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 48 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 49 | }, 50 | 'auth.user': { 51 | 'Meta': {'object_name': 'User'}, 52 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 53 | 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), 54 | 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 55 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), 56 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 57 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 58 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 59 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 60 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 61 | 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 62 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 63 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 64 | 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) 65 | }, 66 | 'contenttypes.contenttype': { 67 | 'Meta': {'unique_together': "(('app_label', 'model'),)", 'ordering': "('name',)", 'db_table': "'django_content_type'", 'object_name': 'ContentType'}, 68 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 69 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 70 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 71 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 72 | }, 73 | 'readme.item': { 74 | 'Meta': {'object_name': 'Item'}, 75 | 'created': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}), 76 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 77 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 78 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 79 | 'title': ('django.db.models.fields.TextField', [], {}), 80 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 81 | }, 82 | 'readme.itemtag': { 83 | 'Meta': {'object_name': 'ItemTag'}, 84 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 85 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'unique': 'True'}), 86 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'unique': 'True'}) 87 | }, 88 | 'readme.taggeditem': { 89 | 'Meta': {'object_name': 'TaggedItem'}, 90 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 91 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 92 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']"}) 93 | } 94 | } 95 | 96 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0008_move_tags_to_custom_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import DataMigration 5 | from django.db import models, connection 6 | 7 | class Migration(DataMigration): 8 | 9 | def forwards(self, orm): 10 | cursor = connection.cursor() 11 | table_names = connection.introspection.get_table_list(cursor) 12 | # Nothing to migrate here 13 | if not 'taggit_taggeditem' in table_names: 14 | return 15 | 16 | tagged_item_model = orm['taggit.TaggedItem'] 17 | tagged_items = list(tagged_item_model.objects.all()) 18 | for tagged_item in tagged_items: 19 | tagged_object = orm.Item.objects.get(pk=tagged_item.object_id) 20 | tag_name = tagged_item.tag.name 21 | try: 22 | tag = orm.ItemTag.objects.get(name=tag_name) 23 | except orm.ItemTag.DoesNotExist: 24 | tag = orm.ItemTag.objects.create(name=tag_name, slug=tag_name) 25 | orm.TaggedItem.objects.create(content_object=tagged_object, tag=tag) 26 | 27 | def backwards(self, orm): 28 | pass 29 | 30 | models = { 31 | 'auth.group': { 32 | 'Meta': {'object_name': 'Group'}, 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), 35 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False'}) 36 | }, 37 | 'auth.permission': { 38 | 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission'}, 39 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 40 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 41 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 42 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 43 | }, 44 | 'auth.user': { 45 | 'Meta': {'object_name': 'User'}, 46 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), 48 | 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 49 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True', 'related_name': "'user_set'"}), 50 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 51 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 52 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 53 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 54 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 55 | 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 56 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 57 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True', 'related_name': "'user_set'"}), 58 | 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) 59 | }, 60 | 'contenttypes.contenttype': { 61 | 'Meta': {'db_table': "'django_content_type'", 'unique_together': "(('app_label', 'model'),)", 'ordering': "('name',)", 'object_name': 'ContentType'}, 62 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 63 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 64 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 65 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 66 | }, 67 | 'readme.item': { 68 | 'Meta': {'object_name': 'Item'}, 69 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 70 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 71 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 72 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 73 | 'title': ('django.db.models.fields.TextField', [], {}), 74 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 75 | }, 76 | 'readme.itemtag': { 77 | 'Meta': {'object_name': 'ItemTag'}, 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'unique': 'True'}), 80 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'unique': 'True'}) 81 | }, 82 | 'readme.taggeditem': { 83 | 'Meta': {'object_name': 'TaggedItem'}, 84 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 85 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 86 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 87 | }, 88 | 'taggit.tag': { 89 | 'Meta': {'object_name': 'Tag'}, 90 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 91 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 92 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 93 | }, 94 | 'taggit.taggeditem': { 95 | 'Meta': {'object_name': 'TaggedItem'}, 96 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_tagged_items'", 'to': u"orm['contenttypes.ContentType']"}), 97 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 98 | 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), 99 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_items'", 'to': u"orm['taggit.Tag']"}) 100 | } 101 | } 102 | 103 | complete_apps = ['readme'] 104 | symmetrical = False 105 | -------------------------------------------------------------------------------- /readme/migrations/0009_auto__add_userprofile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'UserProfile' 12 | db.create_table('readme_userprofile', ( 13 | ('user', self.gf('django.db.models.fields.related.OneToOneField')(primary_key=True, to=orm['auth.User'], unique=True)), 14 | ('theme', self.gf('django.db.models.fields.CharField')(max_length=30)), 15 | ('can_invite', self.gf('django.db.models.fields.BooleanField')(default=True)), 16 | )) 17 | db.send_create_signal('readme', ['UserProfile']) 18 | 19 | 20 | def backwards(self, orm): 21 | # Deleting model 'UserProfile' 22 | db.delete_table('readme_userprofile') 23 | 24 | 25 | models = { 26 | 'auth.group': { 27 | 'Meta': {'object_name': 'Group'}, 28 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 29 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), 30 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'blank': 'True'}) 31 | }, 32 | 'auth.permission': { 33 | 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission', 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"}, 34 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 35 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 36 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 37 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 38 | }, 39 | 'auth.user': { 40 | 'Meta': {'object_name': 'User'}, 41 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 42 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 43 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 44 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Group']", 'related_name': "'user_set'", 'blank': 'True'}), 45 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 46 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 47 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 48 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 49 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 50 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 51 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 52 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'blank': 'True'}), 53 | 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) 54 | }, 55 | 'contenttypes.contenttype': { 56 | 'Meta': {'db_table': "'django_content_type'", 'ordering': "('name',)", 'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)"}, 57 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 58 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 59 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 60 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 61 | }, 62 | 'readme.item': { 63 | 'Meta': {'object_name': 'Item'}, 64 | 'created': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}), 65 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 66 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 67 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 68 | 'title': ('django.db.models.fields.TextField', [], {}), 69 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 70 | }, 71 | 'readme.itemtag': { 72 | 'Meta': {'object_name': 'ItemTag'}, 73 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 74 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'unique': 'True'}), 75 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'unique': 'True'}) 76 | }, 77 | 'readme.taggeditem': { 78 | 'Meta': {'object_name': 'TaggedItem'}, 79 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 80 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 81 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 82 | }, 83 | 'readme.userprofile': { 84 | 'Meta': {'object_name': 'UserProfile'}, 85 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 86 | 'theme': ('django.db.models.fields.CharField', [], {'max_length': '30'}), 87 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'primary_key': 'True', 'to': "orm['auth.User']", 'unique': 'True'}) 88 | } 89 | } 90 | 91 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0010_create_userprofiles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import DataMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(DataMigration): 9 | 10 | def forwards(self, orm): 11 | for user in orm['auth.User'].objects.all(): 12 | orm.UserProfile.objects.get_or_create(user=user) 13 | # Note: Don't use "from appname.models import ModelName". 14 | # Use orm.ModelName to refer to models in this application, 15 | # and orm['appname.ModelName'] for models in other applications. 16 | 17 | def backwards(self, orm): 18 | pass 19 | 20 | 21 | models = { 22 | 'auth.group': { 23 | 'Meta': {'object_name': 'Group'}, 24 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 25 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 26 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 27 | }, 28 | 'auth.permission': { 29 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 30 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 31 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 32 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 33 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 34 | }, 35 | 'auth.user': { 36 | 'Meta': {'object_name': 'User'}, 37 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 38 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 39 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 40 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), 41 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 42 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 43 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 44 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 46 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 47 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 48 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), 49 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 50 | }, 51 | 'contenttypes.contenttype': { 52 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 53 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 54 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 55 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 56 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 57 | }, 58 | 'readme.item': { 59 | 'Meta': {'object_name': 'Item'}, 60 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 61 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 62 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 63 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 64 | 'title': ('django.db.models.fields.TextField', [], {}), 65 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 66 | }, 67 | 'readme.itemtag': { 68 | 'Meta': {'object_name': 'ItemTag'}, 69 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 70 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 71 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 72 | }, 73 | 'readme.taggeditem': { 74 | 'Meta': {'object_name': 'TaggedItem'}, 75 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 76 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 77 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 78 | }, 79 | 'readme.userprofile': { 80 | 'Meta': {'object_name': 'UserProfile'}, 81 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 82 | 'theme': ('django.db.models.fields.CharField', [], {'max_length': '30'}), 83 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'primary_key': 'True'}) 84 | } 85 | } 86 | 87 | complete_apps = ['readme'] 88 | symmetrical = True 89 | -------------------------------------------------------------------------------- /readme/migrations/0011_auto__add_field_item_safe_article.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Item.safe_article' 12 | db.add_column('readme_item', 'safe_article', 13 | self.gf('django.db.models.fields.TextField')(null=True), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'Item.safe_article' 19 | db.delete_column('readme_item', 'safe_article') 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'blank': 'True', 'to': "orm['auth.Permission']"}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)"}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | 'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'object_name': 'ContentType', 'db_table': "'django_content_type'", 'unique_together': "(('app_label', 'model'),)"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}), 62 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 64 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 65 | 'safe_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 66 | 'title': ('django.db.models.fields.TextField', [], {}), 67 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 68 | }, 69 | 'readme.itemtag': { 70 | 'Meta': {'object_name': 'ItemTag'}, 71 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 72 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 73 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 74 | }, 75 | 'readme.taggeditem': { 76 | 'Meta': {'object_name': 'TaggedItem'}, 77 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'readme_taggeditem_items'", 'to': "orm['readme.ItemTag']"}) 80 | }, 81 | 'readme.userprofile': { 82 | 'Meta': {'object_name': 'UserProfile'}, 83 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 84 | 'theme': ('django.db.models.fields.CharField', [], {'default': "'slate'", 'max_length': '30'}), 85 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'unique': 'True', 'primary_key': 'True', 'to': "orm['auth.User']"}) 86 | } 87 | } 88 | 89 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0012_save_safe_article.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import DataMigration 5 | from django.db import models 6 | 7 | from readme.models import Item 8 | 9 | class Migration(DataMigration): 10 | 11 | def forwards(self, orm): 12 | for item in orm.Item.objects.all(): 13 | item.safe_article = Item.get_safe_article(item) 14 | item.save() 15 | 16 | def backwards(self, orm): 17 | pass 18 | 19 | models = { 20 | 'auth.group': { 21 | 'Meta': {'object_name': 'Group'}, 22 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 23 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 24 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 25 | }, 26 | 'auth.permission': { 27 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)"}, 28 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 29 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 30 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 31 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 32 | }, 33 | 'auth.user': { 34 | 'Meta': {'object_name': 'User'}, 35 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 36 | 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), 37 | 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 38 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), 39 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 40 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 41 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 42 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 43 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 44 | 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), 45 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 46 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'symmetrical': 'False', 'blank': 'True'}), 47 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 48 | }, 49 | 'contenttypes.contenttype': { 50 | 'Meta': {'ordering': "('name',)", 'object_name': 'ContentType', 'db_table': "'django_content_type'", 'unique_together': "(('app_label', 'model'),)"}, 51 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 52 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 53 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 54 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 55 | }, 56 | 'readme.item': { 57 | 'Meta': {'object_name': 'Item'}, 58 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 59 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 60 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 61 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 62 | 'safe_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 63 | 'title': ('django.db.models.fields.TextField', [], {}), 64 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 65 | }, 66 | 'readme.itemtag': { 67 | 'Meta': {'object_name': 'ItemTag'}, 68 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 69 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 70 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 71 | }, 72 | 'readme.taggeditem': { 73 | 'Meta': {'object_name': 'TaggedItem'}, 74 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 75 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 76 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 77 | }, 78 | 'readme.userprofile': { 79 | 'Meta': {'object_name': 'UserProfile'}, 80 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 81 | 'theme': ('django.db.models.fields.CharField', [], {'default': "'slate'", 'max_length': '30'}), 82 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'primary_key': 'True'}) 83 | } 84 | } 85 | 86 | complete_apps = ['readme'] 87 | symmetrical = True 88 | -------------------------------------------------------------------------------- /readme/migrations/0013_auto__add_field_userprofile_new_window.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'UserProfile.new_window' 12 | db.add_column('readme_userprofile', 'new_window', 13 | self.gf('django.db.models.fields.BooleanField')(default=False), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'UserProfile.new_window' 19 | db.delete_column('readme_userprofile', 'new_window') 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False'}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)"}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | 'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Group']", 'blank': 'True', 'symmetrical': 'False'}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)", 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 64 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 65 | 'safe_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 66 | 'title': ('django.db.models.fields.TextField', [], {}), 67 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 68 | }, 69 | 'readme.itemtag': { 70 | 'Meta': {'object_name': 'ItemTag'}, 71 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 72 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'unique': 'True'}), 73 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'unique': 'True'}) 74 | }, 75 | 'readme.taggeditem': { 76 | 'Meta': {'object_name': 'TaggedItem'}, 77 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 80 | }, 81 | 'readme.userprofile': { 82 | 'Meta': {'object_name': 'UserProfile'}, 83 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 84 | 'new_window': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 85 | 'theme': ('django.db.models.fields.CharField', [], {'max_length': '30', 'default': "'slate'"}), 86 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'primary_key': 'True', 'unique': 'True'}) 87 | } 88 | } 89 | 90 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0014_auto__add_field_userprofile_items_per_page.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'UserProfile.items_per_page' 12 | db.add_column('readme_userprofile', 'items_per_page', 13 | self.gf('django.db.models.fields.PositiveIntegerField')(default=50), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'UserProfile.items_per_page' 19 | db.delete_column('readme_userprofile', 'items_per_page') 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'blank': 'True'}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | 'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Group']", 'related_name': "'user_set'", 'blank': 'True'}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'object_name': 'ContentType', 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 64 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 65 | 'safe_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 66 | 'title': ('django.db.models.fields.TextField', [], {}), 67 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 68 | }, 69 | 'readme.itemtag': { 70 | 'Meta': {'object_name': 'ItemTag'}, 71 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 72 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'unique': 'True'}), 73 | 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'unique': 'True'}) 74 | }, 75 | 'readme.taggeditem': { 76 | 'Meta': {'object_name': 'TaggedItem'}, 77 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.ItemTag']", 'related_name': "'readme_taggeditem_items'"}) 80 | }, 81 | 'readme.userprofile': { 82 | 'Meta': {'object_name': 'UserProfile'}, 83 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 84 | 'items_per_page': ('django.db.models.fields.PositiveIntegerField', [], {'default': '50'}), 85 | 'new_window': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 86 | 'theme': ('django.db.models.fields.CharField', [], {'max_length': '30', 'default': "'slate'"}), 87 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'primary_key': 'True', 'to': "orm['auth.User']", 'unique': 'True'}) 88 | } 89 | } 90 | 91 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/0015_auto__add_field_userprofile_show_excluded.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'UserProfile.show_excluded' 12 | db.add_column('readme_userprofile', 'show_excluded', 13 | self.gf('django.db.models.fields.BooleanField')(default=False), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'UserProfile.show_excluded' 19 | db.delete_column('readme_userprofile', 'show_excluded') 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False'}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)"}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | 'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Group']", 'blank': 'True', 'symmetrical': 'False'}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'db_table': "'django_content_type'", 'ordering': "('name',)", 'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'readme.item': { 60 | 'Meta': {'object_name': 'Item'}, 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 62 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 63 | 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 64 | 'readable_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 65 | 'safe_article': ('django.db.models.fields.TextField', [], {'null': 'True'}), 66 | 'title': ('django.db.models.fields.TextField', [], {}), 67 | 'url': ('django.db.models.fields.URLField', [], {'max_length': '2000'}) 68 | }, 69 | 'readme.itemtag': { 70 | 'Meta': {'object_name': 'ItemTag'}, 71 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 72 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 73 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) 74 | }, 75 | 'readme.taggeditem': { 76 | 'Meta': {'object_name': 'TaggedItem'}, 77 | 'content_object': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['readme.Item']"}), 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'readme_taggeditem_items'", 'to': "orm['readme.ItemTag']"}) 80 | }, 81 | 'readme.userprofile': { 82 | 'Meta': {'object_name': 'UserProfile'}, 83 | 'can_invite': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 84 | 'items_per_page': ('django.db.models.fields.PositiveIntegerField', [], {'default': '50'}), 85 | 'new_window': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 86 | 'show_excluded': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 87 | 'theme': ('django.db.models.fields.CharField', [], {'default': "'slate'", 'max_length': '30'}), 88 | 'user': ('django.db.models.fields.related.OneToOneField', [], {'unique': 'True', 'to': "orm['auth.User']", 'primary_key': 'True'}) 89 | } 90 | } 91 | 92 | complete_apps = ['readme'] -------------------------------------------------------------------------------- /readme/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audax/pypo/58ce116bf76d50faa34977a80075d488736ffcc8/readme/migrations/__init__.py -------------------------------------------------------------------------------- /readme/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.conf import settings 4 | from django.db.models.signals import post_save 5 | from django.dispatch import receiver 6 | from django.utils.functional import cached_property 7 | from tld import get_tld 8 | from django.core.urlresolvers import reverse 9 | from taggit.managers import TaggableManager 10 | from taggit.models import TagBase, ItemBase 11 | from readme.download import download, DownloadException 12 | from readme.scrapers import parse 13 | 14 | import logging 15 | import bleach 16 | 17 | request_log = logging.getLogger('readme.requests') 18 | 19 | 20 | class ItemQuerySet(models.query.QuerySet): 21 | 22 | def tagged(self, *tags): 23 | filtered = self 24 | for tag in tags: 25 | filtered = filtered.filter(tags__name=tag) 26 | return filtered 27 | 28 | def without(self, *tags): 29 | filtered = self 30 | for tag in tags: 31 | filtered = filtered.exclude(tags__name=tag) 32 | return filtered 33 | 34 | 35 | class ItemManager(models.Manager): 36 | 37 | def get_queryset(self): 38 | return ItemQuerySet(self.model) 39 | 40 | def __getattr__(self, name, *args): 41 | if name.startswith("_"): 42 | raise AttributeError 43 | return getattr(self.get_query_set(), name, *args) 44 | 45 | class ItemTag(TagBase): 46 | 47 | def slugify(self, tag, i=None): 48 | return tag 49 | 50 | class TaggedItem(ItemBase): 51 | 52 | tag = models.ForeignKey(ItemTag, related_name="%(app_label)s_%(class)s_items") 53 | content_object = models.ForeignKey('Item') 54 | 55 | @classmethod 56 | def tags_for(cls, model, instance=None): 57 | if instance is not None: 58 | return cls.tag_model().objects.filter(**{ 59 | '%s__content_object' % cls.tag_relname(): instance 60 | }) 61 | return cls.tag_model().objects.filter(**{ 62 | '%s__content_object__isnull' % cls.tag_relname(): False 63 | }).distinct() 64 | 65 | class Item(models.Model): 66 | """ 67 | Entry in the read-it-later-list 68 | 69 | """ 70 | #:param url Page url 71 | url = models.URLField(max_length=2000) 72 | #:param title Page title 73 | title = models.TextField(blank=True) 74 | #:param created Creating date of the item 75 | created = models.DateTimeField(auto_now_add=True) 76 | #:param owner Owning user 77 | owner = models.ForeignKey(User) 78 | #:param readable_article Processed content of the url 79 | readable_article = models.TextField(blank=True) 80 | #:param safe_article Escaped and stripped of tags 81 | safe_article = models.TextField(blank=True) 82 | #:param tags User assigned tags 83 | tags = TaggableManager(blank=True, through=TaggedItem) 84 | 85 | objects = ItemManager() 86 | 87 | @property 88 | def created_as_str(self): 89 | created = self.created.isoformat().split('+')[0] 90 | return created + 'Z' 91 | 92 | @cached_property 93 | def domain(self): 94 | """ 95 | Domain of the url 96 | """ 97 | return get_tld(self.url, fail_silently=True) 98 | 99 | def get_absolute_url(self): 100 | return reverse('item_view', args=[str(self.id)]) 101 | 102 | def get_api_url(self): 103 | return '/api/items/{}/'.format(str(self.id)) 104 | 105 | def get_update_url(self): 106 | return reverse('item_update', args=[str(self.id)]) 107 | 108 | def get_tag_names(self): 109 | return sorted(self.tags.values_list('name', flat=True)) 110 | 111 | @property 112 | def tag_names(self): 113 | try: 114 | return self.get_tag_names() 115 | except ValueError: 116 | return self._tags_to_save 117 | 118 | @tag_names.setter 119 | def tag_names(self, names): 120 | if self.tag_names or names: 121 | self.tags.set(*names) 122 | else: 123 | self._tags_to_save = names 124 | 125 | def get_safe_article(self): 126 | if self.readable_article: 127 | return bleach.clean(self.readable_article, strip=True, tags=[]) 128 | else: 129 | return '' 130 | 131 | def save(self, *args, **kwargs): 132 | self.safe_article = self.get_safe_article() 133 | super(Item, self).save(*args, **kwargs) 134 | 135 | def fetch_article(self): 136 | """ 137 | Fetches a title and a readable_article for the current url. 138 | It uses the scrapers module for this and only downloads the content. 139 | """ 140 | try: 141 | dl = download(self.url, max_content_length=settings.PYPO_MAX_CONTENT_LENGTH) 142 | except DownloadException: 143 | # TODO show a message that the download failed? 144 | self.title = self.url 145 | self.readable_article = None 146 | else: 147 | self.title, self.readable_article = parse(self, content_type=dl.content_type, 148 | text=dl.text, content=dl.content) 149 | 150 | 151 | class UserProfile(models.Model): 152 | user = models.OneToOneField(User, primary_key=True) 153 | theme = models.CharField('Custom Theme', max_length=30, default=settings.PYPO_DEFAULT_THEME) 154 | can_invite = models.BooleanField(default=True) 155 | new_window = models.BooleanField('Open links in a new page', default=False) 156 | items_per_page = models.PositiveIntegerField('Number of items shown on a page', default=50) 157 | excluded_tags = TaggableManager('Tags that can be excluded', blank=True) 158 | show_excluded = models.BooleanField('Show excluded items anyway', default=False) 159 | 160 | 161 | @property 162 | def id(self): 163 | """ 164 | Just to tell the TaggableManager what the primary key of this model is 165 | """ 166 | return self.user.id 167 | 168 | @receiver(post_save, sender=User) 169 | def create_profile_for_new_user(sender, created, instance, **kwargs): 170 | if created: 171 | profile = UserProfile(user=instance) 172 | profile.save() 173 | -------------------------------------------------------------------------------- /readme/scrapers.py: -------------------------------------------------------------------------------- 1 | from lxml.html import document_fromstring 2 | 3 | 4 | class ParserException(Exception): 5 | ''' 6 | Generic exception for parsers/scrapers. 7 | Indicates that a scraper cannot succeed. 8 | ''' 9 | pass 10 | 11 | 12 | def parse(item, content_type, text=None, content=None): 13 | """ 14 | Scrape info from an item 15 | 16 | :param content_type: mime type 17 | :param text: unicode text 18 | :param content: byte string 19 | :param item: Item 20 | """ 21 | try: 22 | domain = item.domain 23 | if text is not None: 24 | if domain in parse.domains: 25 | return parse.domains[domain](item, content_type, text) 26 | else: 27 | return parse_web_page(text) 28 | except ParserException: 29 | pass 30 | return item.url, '' 31 | 32 | 33 | parse.domains = {} 34 | 35 | 36 | def domain_parser(domain): 37 | """ 38 | Decorator to register a domain specific parser 39 | 40 | :param domain: String 41 | :return: function 42 | """ 43 | def decorator(func): 44 | parse.domains[domain] = func 45 | return func 46 | return decorator 47 | 48 | 49 | def parse_web_page(text): 50 | """ 51 | Generic wep page parser with readability. 52 | Used as a fallback. 53 | 54 | :param text: unicode text 55 | :return: title, article 56 | :raise ParserException: 57 | """ 58 | try: 59 | from readability import Document 60 | from readability.readability import Unparseable 61 | except ImportError: 62 | raise ParserException('readability is not installed') 63 | 64 | if not text: 65 | raise ParserException('No decoded text available, aborting!') 66 | try: 67 | doc = Document(text) 68 | except Unparseable as e: 69 | raise ParserException(e.message) 70 | else: 71 | return doc.short_title(), doc.summary(True) 72 | 73 | 74 | @domain_parser('github.com') 75 | @domain_parser('bitbucket.org') 76 | def parse_github(item, content_type, text): 77 | """ 78 | Reads the readme of a repo if it can find one. 79 | 80 | :param item: ignored 81 | :param content_type: ignored 82 | :param text: unicode text 83 | :return: title, article 84 | :raise ParserException: raised of no readme is found 85 | """ 86 | if text is None: 87 | raise ParserException('Could not decode content') 88 | doc = document_fromstring(text) 89 | readme_elements = doc.cssselect('#readme article') 90 | if readme_elements: 91 | readme = readme_elements[0] 92 | readme_title = readme.cssselect('h1') 93 | if readme_title: 94 | readme_title[0].drop_tree() 95 | article = readme.text_content() 96 | else: 97 | raise ParserException('readme not found') 98 | title_elements = doc.cssselect('title') 99 | if title_elements: 100 | title = title_elements[0].text_content() 101 | else: 102 | raise ParserException('title not found') 103 | return title, article 104 | -------------------------------------------------------------------------------- /readme/search_indexes.py: -------------------------------------------------------------------------------- 1 | from haystack import indexes 2 | from readme.models import Item 3 | 4 | 5 | class ItemIndex(indexes.SearchIndex, indexes.Indexable): 6 | text = indexes.NgramField(document=True, use_template=True) 7 | title = indexes.CharField(model_attr='title') 8 | created = indexes.DateTimeField(model_attr='created') 9 | owner_id = indexes.IntegerField(model_attr='owner_id') 10 | tags = indexes.MultiValueField(faceted=True) 11 | 12 | def prepare_tags(self, obj): 13 | if not type(obj.tags) is list: 14 | return list(obj.tags.names()) 15 | else: 16 | return obj.tags 17 | 18 | def get_model(self): 19 | return Item 20 | 21 | def index_queryset(self, using=None): 22 | """Used when the entire index for model is updated.""" 23 | return self.get_model().objects.all() -------------------------------------------------------------------------------- /readme/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, Group 2 | from rest_framework import serializers 3 | from .models import Item 4 | 5 | class UserSerializer(serializers.HyperlinkedModelSerializer): 6 | class Meta: 7 | model = User 8 | fields = ('url', 'username', 'email', 'groups') 9 | 10 | 11 | class GroupSerializer(serializers.HyperlinkedModelSerializer): 12 | class Meta: 13 | model = Group 14 | fields = ('url', 'name') 15 | 16 | class TagSerializer(serializers.Field): 17 | def to_representation(self, data): 18 | return data 19 | 20 | def to_internal_value(self, obj): 21 | return sorted(obj) 22 | 23 | class ItemSerializer(serializers.ModelSerializer): 24 | tags = TagSerializer(source='tag_names') 25 | title = serializers.CharField(required=False) 26 | readable_article = serializers.CharField(required=False) 27 | class Meta: 28 | model = Item 29 | fields = ('id', 'url', 'title', 'created', 'readable_article', 'tags') 30 | 31 | def create(self, validated_data): 32 | tags = validated_data.pop('tag_names') 33 | item = Item(**validated_data) 34 | item.fetch_article() 35 | item.save() 36 | item.tag_names = tags 37 | item.save() 38 | return item 39 | -------------------------------------------------------------------------------- /readme/signals.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from readme.models import Item 3 | from haystack import signals 4 | 5 | 6 | class ItemOnlySignalProcessor(signals.BaseSignalProcessor): 7 | def setup(self): 8 | # Listen only to the ``User`` model. 9 | models.signals.post_save.connect(self.handle_save, sender=Item) 10 | models.signals.post_delete.connect(self.handle_delete, sender=Item) 11 | 12 | def teardown(self): 13 | # Disconnect only for the ``User`` model. 14 | models.signals.post_save.disconnect(self.handle_save, sender=Item) 15 | models.signals.post_delete.disconnect(self.handle_delete, sender=Item) 16 | -------------------------------------------------------------------------------- /readme/static/css/readme.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 768px) { 2 | #add_item_popover .popover { 3 | width: 600px; 4 | max-width: none; 5 | } 6 | #add_item_popover .popover .arrow { 7 | display: none; 8 | } 9 | .item { 10 | height: 256px; 11 | margin-bottom: 16px; 12 | } 13 | .item .panel { 14 | height: 100%; 15 | overflow-y: hidden; 16 | } 17 | .item .panel .panel-heading { 18 | height: 56px; 19 | transition: 0.5s ease; 20 | overflow: hidden; 21 | } 22 | .item .panel .panel-heading .item_title { 23 | height: 24px; 24 | } 25 | .item .panel .panel-heading .item_domain { 26 | height: 24px; 27 | margin: 0 0 0 0; 28 | } 29 | .item .panel .panel-heading .item_tools { 30 | height: 228px; 31 | } 32 | .item .panel .panel-heading .header_buttons { 33 | display: block; 34 | left: -4px; 35 | } 36 | .item .panel .panel-heading .delete_link { 37 | position: absolute; 38 | left: 2px; 39 | } 40 | .item .panel .panel-body { 41 | height: 216px; 42 | } 43 | .item.active_item .panel-heading { 44 | height: 130px; 45 | } 46 | .item.active_item .panel-body { 47 | height: 126px; 48 | } 49 | } 50 | .item { 51 | word-break: break-all; 52 | } 53 | .header_buttons { 54 | display: none; 55 | } 56 | .panel .panel-heading { 57 | padding-left: 0; 58 | } 59 | .panel .panel-heading .item_tools { 60 | padding-left: 14px; 61 | } 62 | .item_title, 63 | .item_domain { 64 | text-overflow: ellipsis; 65 | white-space: nowrap; 66 | overflow: hidden; 67 | } 68 | body { 69 | padding-top: 70px; 70 | } 71 | -------------------------------------------------------------------------------- /readme/static/css/readme.less: -------------------------------------------------------------------------------- 1 | @media (min-width: 768px) { 2 | 3 | #add_item_popover .popover { 4 | width: 600px; 5 | max-width: none; 6 | .arrow { 7 | display: none; 8 | } 9 | } 10 | 11 | .item { 12 | height: 256px; 13 | margin-bottom: 16px; 14 | 15 | .panel { 16 | height: 100%; 17 | overflow-y: hidden; 18 | 19 | .panel-heading { 20 | height: 56px; 21 | transition: 0.5s ease; 22 | overflow: hidden; 23 | .item_title { 24 | height: 24px; 25 | } 26 | .item_domain { 27 | height: 24px; 28 | margin: 0 0 0 0; 29 | } 30 | .item_tools { 31 | height: 228px; 32 | } 33 | .header_buttons { 34 | display: block; 35 | left: -4px; 36 | } 37 | .delete_link { 38 | position: absolute; 39 | left: 2px; 40 | } 41 | 42 | } 43 | .panel-body { 44 | height: 216px; 45 | } 46 | } 47 | } 48 | 49 | .item.active_item { 50 | .panel-heading { 51 | height: 130px; 52 | } 53 | .panel-body { 54 | height: 126px; 55 | } 56 | } 57 | } 58 | 59 | .item { 60 | word-break: break-all; 61 | } 62 | .header_buttons { 63 | display: none; 64 | } 65 | 66 | .panel { 67 | .panel-heading { 68 | padding-left: 0; 69 | .item_tools { 70 | padding-left: 14px; 71 | } 72 | } 73 | } 74 | 75 | .item_title, .item_domain { 76 | text-overflow: ellipsis; 77 | white-space: nowrap; 78 | overflow: hidden; 79 | 80 | } 81 | 82 | body { 83 | // padding for the nav bar 84 | padding-top: 70px; 85 | } 86 | 87 | -------------------------------------------------------------------------------- /readme/static/js/readme.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | "use strict"; 3 | 4 | if (window.PYPO === undefined) { 5 | window.PYPO = {}; 6 | } 7 | 8 | function getCookie(name) { 9 | var cookieValue = null; 10 | if (document.cookie && document.cookie != '') { 11 | var cookies = document.cookie.split(';'); 12 | for (var i = 0; i < cookies.length; i++) { 13 | var cookie = jQuery.trim(cookies[i]); 14 | // Does this cookie string begin with the name we want? 15 | if (cookie.substring(0, name.length + 1) == (name + '=')) { 16 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 17 | break; 18 | } 19 | } 20 | } 21 | return cookieValue; 22 | } 23 | var csrftoken = getCookie('csrftoken'); 24 | 25 | function csrfSafeMethod(method) { 26 | // these HTTP methods do not require CSRF protection 27 | return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); 28 | } 29 | function sameOrigin(url) { 30 | // test that a given url is a same-origin URL 31 | // url could be relative or scheme relative or absolute 32 | var host = document.location.host; // host + port 33 | var protocol = document.location.protocol; 34 | var sr_origin = '//' + host; 35 | var origin = protocol + sr_origin; 36 | // Allow absolute or scheme relative URLs to same origin 37 | return (url == origin || url.slice(0, origin.length + 1) == origin + '/') || 38 | (url == sr_origin || url.slice(0, sr_origin.length + 1) == sr_origin + '/') || 39 | // or any other URL that isn't scheme relative or absolute i.e relative. 40 | !(/^(\/\/|http:|https:).*/.test(url)); 41 | } 42 | $.ajaxSetup({ 43 | beforeSend: function(xhr, settings) { 44 | if (!csrfSafeMethod(settings.type) && sameOrigin(settings.url)) { 45 | // Send the token to same-origin, relative URLs only. 46 | // Send the token only if the method warrants CSRF protection 47 | // Using the CSRFToken value acquired earlier 48 | xhr.setRequestHeader("X-CSRFToken", csrftoken); 49 | } 50 | } 51 | }); 52 | 53 | 54 | $('[data-toggle="confirmation"]').popConfirm({ 55 | title: 'Do you want to delete this item?', 56 | content: '', 57 | placement: 'bottom', 58 | yesBtn: 'Yes', 59 | noBtn: 'No', 60 | yesCallBack: function (self) { 61 | $.ajax({ 62 | url: self.data('item-api-url'), 63 | success: function () { 64 | self.closest('div.item').fadeOut(); 65 | }, 66 | type: 'DELETE' 67 | }) 68 | } 69 | }); 70 | var paramFunction = function (params) { 71 | var data = {}; 72 | data['id'] = params.pk; 73 | data[params.name] = params.value; 74 | // emulate patch request, see 75 | // https://github.com/ariya/phantomjs/issues/11384 76 | return JSON.stringify(data); 77 | } 78 | var paramFunctionForTags = function (params) { 79 | params['name'] = 'tags'; 80 | return paramFunction(params); 81 | } 82 | 83 | $('.editable').editable({ 84 | disabled: true, 85 | placement: 'auto', 86 | container: 'body', 87 | ajaxOptions: { 88 | type: 'POST', 89 | headers: {'X-HTTP-Method-Override': 'PATCH'}, 90 | dataType: 'JSON', 91 | contentType: 'application/json' 92 | }, 93 | params: paramFunction 94 | }); 95 | 96 | $('.editable-tags').editable({ 97 | placement: 'bottom', 98 | container: 'body', 99 | disabled: true, 100 | type: 'select2', 101 | ajaxOptions: { 102 | type: 'POST', 103 | headers: {'X-HTTP-Method-Override': 'PATCH'}, 104 | dataType: 'JSON', 105 | contentType: 'application/json' 106 | }, 107 | params: paramFunctionForTags, 108 | select2: { 109 | tags: window.PYPO.tags, 110 | tokenSeparators: [","], 111 | width: '256px', 112 | openOnEnter: false 113 | } 114 | }); 115 | $('#id_enable_editable').click(function(e) { 116 | e.preventDefault(); 117 | $(this).parent().toggleClass('active'); 118 | $('.editable').editable('toggleDisabled'); 119 | }); 120 | $('.link_toolbox').click(function (e) { 121 | e.preventDefault(); 122 | var $this = $(this); 123 | $this.closest('.item').toggleClass('active_item'); 124 | $this.toggleClass('active'); 125 | }) 126 | 127 | var protocol_regexp = new RegExp("^https?://"); 128 | 129 | var setup_item_form = function (url_field, tags_field) { 130 | tags_field.select2({ 131 | tags: window.PYPO.tags, 132 | width: '100%', 133 | tokenSeparators: [","], 134 | openOnEnter: false, 135 | selectOnBlur: true 136 | }); 137 | 138 | url_field.blur(function() { 139 | var $this = $(this); 140 | var value = $this.val(); 141 | if (!protocol_regexp.test(value)) { 142 | $this.val('http://'+value); 143 | } 144 | }) 145 | } 146 | 147 | if ($('#id_url').length === 1) { 148 | setup_item_form($('#id_url'), $('#id_tags')); 149 | } 150 | 151 | $('#id_add_form').popover({ 152 | html : true, 153 | placement: 'bottom', 154 | container: '#add_item_popover', 155 | title: 'Add a new link', 156 | content: function() { 157 | return $("#add_item_popover_content").html(); 158 | } 159 | }).on('shown.bs.popover', function () { 160 | setup_item_form($('.popover-content input[name="url"]'), $('.popover-content input[name="tags"]')); 161 | $('#id_add_form').parent().addClass('active'); 162 | }).on('hidden.bs.popover', function() { 163 | $('#id_add_form').parent().removeClass('active'); 164 | }); 165 | }); 166 | 167 | -------------------------------------------------------------------------------- /readme/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load pipeline %} 2 | {% load static from staticfiles %} 3 | 4 | 5 | 6 | {% block title %}PyPo{% endblock %} 7 | 8 | {% stylesheet 'all' %} 9 | {% with user.userprofile.theme|default:PYPO_DEFAULT_THEME as theme_name %} 10 | {% with "bootswatch/"|add:theme_name|add:"/bootstrap.min.css" as theme %} 11 | 12 | {% endwith %} 13 | {% endwith %} 14 | {% block CSS %} 15 | {% endblock %} 16 | 17 | 18 | 98 |
99 | {% block content %}{% endblock %} 100 |
101 | 102 |
103 |
104 | {% csrf_token %} 105 |
106 | 107 | 108 |
109 | 111 |
112 |
113 |
114 | 115 | 116 |
117 | 118 |
119 |
120 |
121 | 122 |
123 |
124 | 125 |
126 | 127 |
128 | 129 |
130 | 131 | 132 | 136 | {% javascript 'components' %} 137 | {% block JS %} 138 | {% endblock %} 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /readme/templates/entrance.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load sitegate %} 3 | {% block content %} 4 |
5 |
6 |
7 |

8 | Login 9 |

10 |
11 |
12 | {% sitegate_signin_form %} 13 |
14 |
15 | 16 | 17 | 18 |
19 |
20 |

21 | Signup 22 |

23 |
24 |
25 | {% sitegate_signup_form %} 26 |
27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /readme/templates/readme/form_signin.html: -------------------------------------------------------------------------------- 1 | {% extends "sitegate/signin/_form_base.html" %} 2 | {% load i18n %} 3 | {% block fields %} 4 | {% if signin_form.non_field_errors %} 5 |
6 |

Your username and password didn't match. Please try again.

7 |

Reset your password

8 |
9 | {% endif %} 10 | {% for field in signin_form %} 11 |
12 | {% if field.errors %}
{{ field.errors.0 }}
{% endif %} 13 | {{ field }} 14 | {% if field.help_text %}{{ field.help_text }}{% endif %} 15 |
16 | {% endfor %} 17 | 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /readme/templates/readme/invite.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for code in codes %} 18 | 19 | 20 | 21 | 22 | 23 | 35 | 36 | {% endfor %} 37 |
CodeCreatedAcceptedUsed byDelete
{{ code.code }}{{ code.time_created }}{{ code.time_accepted|default_if_none:"" }}{{ code.acceptor.username }} 24 | {% if not code.expired %} 25 |
26 | 27 | {% csrf_token %} 28 | 32 |
33 | {% endif %} 34 |
38 |
39 |
40 |
41 | {% csrf_token %} 42 | 43 |
44 |
45 |
46 | {% endblock %} -------------------------------------------------------------------------------- /readme/templates/readme/item_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | {% csrf_token %} 5 |
6 | 8 |
9 |
10 |
11 | {{ item.title }} 12 | {% if item.domain %}[{{ item.domain }}]{% endif %} - {{ item.created }} 13 |
14 | {{ item.summary | safe }} 15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /readme/templates/readme/item_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | {% include "readme/item_single.html" %} 5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /readme/templates/readme/item_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load static from staticfiles %} 5 | 6 | {% block JS %} 7 | 15 | {% endblock %} 16 | 17 | {% block content %} 18 | {% crispy form %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /readme/templates/readme/item_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 |
5 |
6 | {% for item in current_item_list %} 7 | {% include "readme/item_single.html" %} 8 | {% endfor %} 9 |
10 |
11 | 41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /readme/templates/readme/item_single.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 |
3 |
4 |
5 |
6 | 18 |

19 | {% if item.domain %}[{{ item.domain }}]{% endif %} 20 |

21 |
22 | 23 | 24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 |

{{ item.created|naturaltime }}

32 | 37 | {% for tag in item.tags.all %} 38 | {{ tag.name }} 39 | {% if not forloop.last %}, {% endif %} 40 | {% endfor %} 41 | 42 |
43 |
44 |
45 |
46 | 48 | 49 | 50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | 63 | {{ item.safe_article|truncatechars:250|safe}} 64 | 65 |
66 |
67 |
68 |
69 |
70 | -------------------------------------------------------------------------------- /readme/templates/readme/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load static from staticfiles %} 5 | 6 | {% block content %} 7 | 17 |
18 | {% crispy form %} 19 |
20 | {% endblock %} 21 | 22 | {% block JS %} 23 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /readme/templates/readme/tests/setup.html: -------------------------------------------------------------------------------- 1 | {% load pipeline %} 2 | 3 | 4 | 5 | 6 | Setup test 7 | {% stylesheet 'testing' %} 8 | {% stylesheet 'all' %} 9 | 10 | 11 |
12 |
13 | {% javascript 'components' %} 14 | {% javascript 'testing' %} 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /readme/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

{{ title }}

5 | {% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /readme/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 | {% if form.errors %} 7 |

Your username and password didn't match. Please try again.

8 |

Reset your password

9 | {% endif %} 10 | 11 |
12 | {% csrf_token %} 13 | {{ form | crispy }} 14 | 15 | 16 |
17 | 18 | {% endblock %} 19 | 20 | -------------------------------------------------------------------------------- /readme/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |

{% trans 'Password change successful' %}

7 | 8 |

{% trans 'Your password was changed.' %}

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /readme/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |
5 | 6 |
{% csrf_token %} 7 |
8 | {% if form.errors %} 9 |

10 | {% blocktrans count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} 11 |

12 | {% endif %} 13 | 14 |

{% trans 'Password change' %}

15 | 16 |

{% trans "Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly." %}

17 | 18 |
19 | 20 |
21 | {{ form.old_password.errors }} 22 | {{ form.old_password }} 23 |
24 | 25 |
26 | {{ form.new_password1.errors }} 27 | {{ form.new_password1 }} 28 |
29 | 30 |
31 | {{ form.new_password2.errors }} 32 | {{ form.new_password2 }} 33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 |
44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /readme/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |

{% trans 'Password reset complete' %}

7 | 8 |

{% trans "Your password has been set. You may go ahead and log in now." %}

9 | 10 |

{% trans 'Log in' %}

11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /readme/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 | 5 | {% if validlink %} 6 | 7 |

{% trans 'Enter new password' %}

8 | 9 |

{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}

10 | 11 |
{% csrf_token %} 12 | {{ form.new_password1.errors }} 13 |

{{ form.new_password1 }}

14 | {{ form.new_password2.errors }} 15 |

{{ form.new_password2 }}

16 |

17 |
18 | 19 | {% else %} 20 | 21 |

{% trans 'Password reset unsuccessful' %}

22 | 23 |

{% trans "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}

24 | 25 | {% endif %} 26 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /readme/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |

{% trans 'Password reset successful' %}

7 | 8 |

{% trans "We've emailed you instructions for setting your password to the email address you submitted. You should be receiving it shortly." %}

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /readme/templates/registration/password_reset_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} 3 | 4 | {% trans "Please go to the following page and choose a new password:" %} 5 | {% block reset_link %} 6 | {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %} 7 | {% endblock %} 8 | {% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} 9 | 10 | {% trans "Thanks for using our site!" %} 11 | 12 | {% blocktrans %}The {{ site_name }} team{% endblocktrans %} 13 | 14 | {% endautoescape %} 15 | -------------------------------------------------------------------------------- /readme/templates/registration/password_reset_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |

{% trans "Password reset" %}

7 | 8 |

{% trans "Forgotten your password? Enter your email address below, and we'll email instructions for setting a new one." %}

9 | 10 |
{% csrf_token %} 11 | {{ form.email.errors }} 12 |

{{ form.email }}

13 |
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /readme/templates/search/indexes/readme/item_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.title }} 2 | {{ object.url }} 3 | {% for tag in object.tags.all %} {{ tag.name }} {% endfor %} 4 | -------------------------------------------------------------------------------- /readme/templates/search/search.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load crispy_forms_tags %} 3 | {% block content %} 4 |
5 |
6 | {% crispy form %} 7 |
8 |
9 | 10 | {% if query %} 11 | 12 |
13 |
14 |
15 | {% for result in page.object_list %} 16 | {% with result.object as item %} 17 | {% include "readme/item_single.html" %} 18 | {% endwith %} 19 | {% empty %} 20 |

No results found.

21 | {% endfor %} 22 |
23 |
24 | 47 |
48 | {% endif %} 49 | 50 | {% endblock %} -------------------------------------------------------------------------------- /readme/test_item.py: -------------------------------------------------------------------------------- 1 | from .models import Item 2 | from conftest import QUEEN 3 | 4 | EXAMPLE_COM = 'http://www.example.com/' 5 | 6 | def test_unknown_tld(): 7 | item = Item() 8 | item.url = 'foobar' 9 | assert item.domain is None 10 | 11 | 12 | def test_find_items_by_tag(user, simple_items): 13 | assert {simple_items['item_fish'], simple_items['item_box']} == set(simple_items['filter'].tagged(QUEEN)) 14 | 15 | 16 | def test_find_items_by_multiple_tags(user, simple_items): 17 | assert simple_items['item_fish'] == simple_items['filter'].tagged(QUEEN, 'fish').get() 18 | assert simple_items['item_box'] == simple_items['filter'].tagged(QUEEN, 'box').get() 19 | 20 | 21 | def test_chain_tag_filters(user, simple_items): 22 | assert simple_items['item_fish'] == simple_items['filter'].tagged(QUEEN).tagged('fish').get() 23 | assert simple_items['item_box'] == simple_items['filter'].tagged(QUEEN).tagged('box').get() 24 | 25 | 26 | def test_filtering_out(user, tagged_items): 27 | tags = [QUEEN, 'fish'] 28 | queryset = Item.objects.filter(owner_id=user.id).tagged(*tags) 29 | assert len(queryset) == 1, "Exactly one item with these tags should be found, but found: {}".format( 30 | '/ '.join("Item with tags {}".format(item.tags.names()) for item in queryset)) 31 | 32 | 33 | def test_excluding_tags(user, tagged_items): 34 | queryset = Item.objects.filter(owner_id=user.id).without(QUEEN) 35 | assert len(queryset) == 2 36 | 37 | queryset = Item.objects.filter(owner_id=user.id).tagged(QUEEN).without(QUEEN) 38 | assert len(queryset) == 0 39 | 40 | def test_tag_names_property(user, simple_items): 41 | item = simple_items['item_fish'] 42 | names = ["bar", "baz", "foo"] 43 | item.tag_names = names 44 | assert item.tag_names == names 45 | -------------------------------------------------------------------------------- /readme/test_readme.py: -------------------------------------------------------------------------------- 1 | from django.core.paginator import Page 2 | from haystack.query import SearchQuerySet 3 | from unittest.mock import Mock 4 | from django.contrib.auth.models import User 5 | from django.core.urlresolvers import reverse, resolve 6 | import requests 7 | from sitegate.models import InvitationCode 8 | from .models import Item 9 | from readme.forms import CreateItemForm 10 | from readme.scrapers import parse 11 | from readme import download 12 | from readme.views import Tag 13 | from conftest import add_example_item, QUEEN 14 | import json 15 | 16 | import pytest 17 | 18 | EXAMPLE_COM = 'http://www.example.com/' 19 | 20 | def test_invalid_html(user): 21 | item = Item.objects.create(url='http://some_invalid_localhost', title='nothing', owner=user) 22 | assert (item.url, '') == parse(item, content_type='text/html', text=None) 23 | 24 | def test_html_is_bleached(user): 25 | content = b'\r\nfoobar\r\n3>5' 26 | item = Item.objects.create(url='http://some_invalid_localhost', title='nothing', owner=user, 27 | readable_article=content) 28 | assert '\nalert(1);foobar\n3>5' == item.safe_article 29 | 30 | def test_item_access_restricted_to_owners(client, db): 31 | item = Item.objects.create(url='http://some_invalid_localhost', title='nothing', 32 | owner=User.objects.create(username='somebody', password='something')) 33 | response = client.get('/view/{}/'.format(item.id)) 34 | assert 302 == response.status_code, 'User did not get redirected trying to access to a foreign item' 35 | 36 | def test_login_required(client, db): 37 | item = Item.objects.create(url='http://some_invalid_localhost', title='nothing', 38 | owner=User.objects.create(username='somebody', password='something')) 39 | urls = ['', '/add/', '/view/{}/'.format(item.id), '/search/'] 40 | for url in urls: 41 | response = client.get(url) 42 | assert 302 == response.status_code, 'url "{}" did not redirect for an anonymus user'.format(url) 43 | 44 | def test_add_item(user_client, get_mock): 45 | response = user_client.post('/add/', {'url': EXAMPLE_COM, 'tags': 'example-tag'}, follow=True) 46 | assert response.status_code == 200 47 | assert EXAMPLE_COM in response.rendered_content 48 | assert 'example-tag' in response.rendered_content 49 | 50 | def test_long_tags_are_truncated(user, user_client): 51 | long_tag = 'foobar'*100 52 | 53 | items = Item.objects.all() 54 | assert len(items) == 0 55 | 56 | response = user_client.post('/add/', { 57 | 'url': EXAMPLE_COM, 58 | 'tags': long_tag 59 | }, follow=True) 60 | assert response.status_code == 200 61 | 62 | items = Item.objects.all() 63 | assert len(items) == 1 64 | item = items[0] 65 | assert item.tags.names()[0] == long_tag[:99] 66 | 67 | 68 | def test_edit_item(user_client, user): 69 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=user) 70 | response = user_client.post('/update/{}/'.format(item.id), {'tags': 'some-tags , are-posted'}, follow=True) 71 | assert response.status_code == 200 72 | assert 'some-tags' in response.rendered_content 73 | assert 'are-posted' in response.rendered_content 74 | item_refreshed = Item.objects.get(pk=item.id) 75 | assert set(item_refreshed.tags.names()) == {'some-tags', 'are-posted'} 76 | 77 | def test_tags_are_shown_in_the_list(user_client, user): 78 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=user) 79 | item.tags.add('foo-tag', 'bar-tag', 'bar tag') 80 | item.save() 81 | response = user_client.get('/') 82 | assert 'foo-tag' in response.rendered_content 83 | assert 'bar-tag' in response.rendered_content 84 | assert set(response.context['tags']) == { 85 | Tag('foo-tag', 1, []), 86 | Tag('bar tag', 1, []), 87 | Tag('bar-tag', 1, [])} 88 | 89 | def test_tag_view_has_abritary_many_arguments(): 90 | match = resolve('/tags/queen/fish') 91 | assert match.kwargs['tags'] == 'queen/fish' 92 | match = resolve('/tags/') 93 | assert match.kwargs['tags'] == '' 94 | 95 | def test_tag_view_filters_items(user_client, user, tagged_items): 96 | 97 | tags = [QUEEN, 'fish'] 98 | queryset = Item.objects.filter(owner_id=user.id).tagged(*tags) 99 | matching_item = queryset.get() 100 | tag_names = ','.join(tags) 101 | response = user_client.get(reverse('tags', kwargs={'tags': tag_names})) 102 | context = response.context 103 | assert {(tag.name, tag.count) for tag in context['tags']} == {(QUEEN, 1), ('fish', 1)} 104 | assert set(context['current_item_list']), {matching_item} 105 | 106 | def test_tag_view_redirects_without_arguments(user_client): 107 | response = user_client.get(reverse('tags', kwargs={'tags': ''})) 108 | assert response.status_code == 302 109 | 110 | 111 | def test_tags_can_have_the_same_slug(user): 112 | first = add_example_item(user, ['some-tag']) 113 | second = add_example_item(user, ['some tag']) 114 | assert first == Item.objects.tagged('some-tag').get() 115 | assert second == Item.objects.tagged('some tag').get() 116 | 117 | def test_create_invite_codes(user_client, user): 118 | assert len(InvitationCode.objects.all()) == 0 119 | response = user_client.post(reverse('invite')) 120 | assert len(response.context['codes']) == 1 121 | code = InvitationCode.objects.all().first() 122 | assert code.creator == user 123 | 124 | def test_delete_invite_codes(user_client, user): 125 | code = InvitationCode.add(user) 126 | assert len(InvitationCode.objects.all()) == 1 127 | response = user_client.post(reverse('invite'), {'id': code.id}) 128 | assert len(response.context['codes']) == 0 129 | assert len(InvitationCode.objects.all()) == 0 130 | 131 | def test_restricted_users_can_delete_invite_codes(user_client, user): 132 | code = InvitationCode.add(user) 133 | assert len(InvitationCode.objects.all()) == 1 134 | user.userprofile.can_invite = False 135 | user.userprofile.save() 136 | response = user_client.post(reverse('invite'), {'id': code.id}) 137 | assert len(response.context['codes']) == 0 138 | assert len(InvitationCode.objects.all()) == 0 139 | 140 | def test_protect_exipred_invite_codes(user_client, user): 141 | assert len(InvitationCode.objects.all()) == 0 142 | code = InvitationCode.add(user) 143 | code.expired = True 144 | code.save() 145 | assert len(InvitationCode.objects.all()) == 1 146 | response = user_client.post(reverse('invite'), {'id': code.id}) 147 | assert len(response.context['codes']) == 1 148 | assert len(InvitationCode.objects.all()) == 1 149 | 150 | def test_restrict_invite_creation(user_client, user): 151 | user.userprofile.can_invite = False 152 | user.userprofile.save() 153 | assert len(InvitationCode.objects.all()) == 0 154 | response = user_client.post(reverse('invite')) 155 | assert len(response.context['codes']) == 0 156 | assert len(InvitationCode.objects.all()) == 0 157 | 158 | def test_can_change_his_profile(user_client, user): 159 | user_client.post(reverse('profile'), { 160 | 'theme': 'journal', 161 | 'items_per_page': '1', 162 | }) 163 | user = User.objects.get(id=user.id) 164 | assert user.userprofile.theme == 'journal' 165 | assert user.userprofile.items_per_page == 1 166 | 167 | 168 | def test_can_exclude_tags(test_index, user_client, user, tagged_items): 169 | user.userprofile.excluded_tags.add('fish') 170 | user.userprofile.save() 171 | 172 | response = user_client.get('/') 173 | assert len(response.context['item_list']) == 3 174 | tags = {(tag.name, tag.count) for tag in response.context['tags']} 175 | # only his own tags are counted 176 | assert {(QUEEN, 2), ('pypo', 1), ('bartender', 1)} == tags 177 | 178 | response = user_client.get('/search/', {'q': 'fish'}) 179 | assert len(response.context['page'].object_list) == 0 180 | 181 | response = user_client.get('/tags/fish') 182 | assert len(response.context['current_item_list']) == 0 183 | 184 | 185 | def test_can_disable_excluding(test_index, user_client, user, tagged_items): 186 | user.userprofile.excluded_tags.add(QUEEN) 187 | user.userprofile.show_excluded = True 188 | user.userprofile.save() 189 | 190 | response = user_client.get('/') 191 | assert len(response.context['item_list']) == 5 192 | tags = {(tag.name, tag.count) for tag in response.context['tags']} 193 | # only his own tags are counted 194 | assert {(QUEEN, 3), ('fish', 2), ('pypo', 1), ('boxing', 1), ('bartender', 1)} == tags 195 | 196 | response = user_client.get('/search/', {'q': 'fish'}) 197 | assert len(response.context['page'].object_list) == 2 198 | 199 | response = user_client.get('/tags/fish') 200 | assert len(response.context['current_item_list']) == 2 201 | 202 | 203 | 204 | def test_facets_are_included_in_the_index_view(test_index, user_client, other_user, tagged_items): 205 | # another item with the same tag by another user 206 | add_example_item(other_user, [QUEEN]) 207 | response = user_client.get('/') 208 | tags = {(tag.name, tag.count) for tag in response.context['tags']} 209 | # only his own tags are counted 210 | assert {(QUEEN, 3), ('fish', 2), ('pypo', 1), ('boxing', 1), ('bartender', 1)} == tags 211 | 212 | def test_index_view_is_paginated(user, user_client, tagged_items): 213 | response = user_client.get('/') 214 | assert isinstance(response.context['current_item_list'], Page) 215 | 216 | p = response.context['current_item_list'] 217 | # start at page 1 218 | assert p.number == 1 219 | 220 | response = user_client.get('/?page=100') 221 | p = response.context['current_item_list'] 222 | # overflowing means that we get the last page 223 | assert p.number == p.paginator.num_pages 224 | 225 | def test_tags_are_saved_as_a_list(user, test_index): 226 | item = Item.objects.create(url=EXAMPLE_COM, title='Example test', 227 | owner=user, readable_article='test') 228 | tags = ['foo', 'bar'] 229 | item.tags.add(*tags) 230 | item.save() 231 | sqs = SearchQuerySet().filter(owner_id=user.id) 232 | assert 1 == len(sqs) 233 | result = sqs[0] 234 | assert set(tags) == set(result.tags) 235 | 236 | def test_search_item_by_title(user_client, user, test_index): 237 | Item.objects.create(url=EXAMPLE_COM, title='Example test', 238 | owner=user, readable_article='test') 239 | response = user_client.get('/search/', {'q': 'Example test'}) 240 | assert 1 == len(response.context['page'].object_list), 'Could not find the test item' 241 | 242 | def test_search_item_by_tag(user_client, user, test_index): 243 | item = Item.objects.create(url=EXAMPLE_COM, title='Example test', 244 | owner=user, readable_article='test') 245 | item.tags.add('example-tag') 246 | item.save() 247 | response = user_client.get('/search/', {'q': 'example-tag'}) 248 | assert 1 == len(response.context['page'].object_list), 'Could not find the test item' 249 | 250 | def test_user_can_only_search_own_items(user_client, user, other_user, test_index): 251 | item = Item.objects.create(url=EXAMPLE_COM, title='Example test', 252 | owner=other_user, readable_article='test') 253 | item.tags.add('example-tag') 254 | item.save() 255 | response = user_client.get('/search/', {'q': 'example-tag'}) 256 | assert 0 == len(response.context['page'].object_list), 'Item from another user found in search' 257 | 258 | def test_tags_are_added_to_form(test_index, user_client, tagged_items): 259 | response = user_client.get('/add/') 260 | tags = [QUEEN, 'fish', 'bartender', 'pypo'] 261 | for tag in tags: 262 | json_tag = json.dumps(tag) 263 | assert json_tag in response.context['tag_names'] 264 | assert json_tag in response.content.decode('utf-8') 265 | 266 | def test_can_query_for_tags(user, test_index, tagged_items): 267 | tags = [QUEEN, 'fish'] 268 | tagged_items = Item.objects.filter(owner=user).tagged(*tags) 269 | # tags__in with multiple calls and single values each _should_ be the same 270 | # as tags=[], but it isn't. Probably a bug in Haystack or Whoosh 271 | sqs = SearchQuerySet().filter(owner_id=user.id) 272 | for tag in tags: 273 | sqs = sqs.filter(tags__in=[tag]) 274 | searched = {result.object for result in sqs} 275 | assert set(tagged_items) == searched 276 | 277 | def test_can_sort_by_creation_time(user, user_client, test_index): 278 | items = [add_example_item(user, ['foobar']) for _ in range(10)] 279 | 280 | response = user_client.get('/search/', {'q': 'foobar', 'sort': 'oldest'}) 281 | results = [result.object for result in response.context['page'].object_list] 282 | assert items == results 283 | 284 | response = user_client.get('/search/', {'q': 'foobar', 'sort': 'newest'}) 285 | results = [result.object for result in response.context['page'].object_list] 286 | assert list(reversed(items)) == results 287 | 288 | 289 | def _mock_content(get_mock, content, content_type="", content_length=1, encoding=None): 290 | return_mock = Mock(headers={'content-type': content_type, 291 | 'content-length': content_length}, 292 | encoding=encoding) 293 | return_mock.iter_content.return_value = iter([content]) 294 | get_mock.return_value = return_mock 295 | 296 | def test_uses_request_to_start_the_download(get_mock): 297 | get_mock.side_effect = requests.RequestException 298 | with pytest.raises(download.DownloadException): 299 | download.download(EXAMPLE_COM) 300 | get_mock.assert_called_with(EXAMPLE_COM, stream=True, verify=False) 301 | 302 | def test_aborts_large_downloads(get_mock): 303 | max_length = 1000 304 | return_mock = Mock(headers={'content-length': max_length+1}) 305 | get_mock.return_value = return_mock 306 | with pytest.raises(download.DownloadException) as cm: 307 | download.download(EXAMPLE_COM, max_length) 308 | assert 'content-length' in cm.value.message 309 | 310 | def test_aborts_with_invalid_headers(get_mock): 311 | return_mock = Mock(headers={'content-length': "invalid"}) 312 | get_mock.return_value = return_mock 313 | with pytest.raises(download.DownloadException) as cm: 314 | download.download(EXAMPLE_COM) 315 | assert 'content-length' in cm.value.message 316 | assert 'convert' in cm.value.message 317 | assert isinstance(cm.value.parent, ValueError) 318 | 319 | def test_item_model_handles_error(get_mock, user): 320 | return_mock = Mock(headers={'content-length': "invalid"}) 321 | get_mock.return_value = return_mock 322 | 323 | item = Item() 324 | item.url = EXAMPLE_COM 325 | item.owner = user 326 | item.fetch_article() 327 | assert item.title == EXAMPLE_COM 328 | assert item.readable_article is None 329 | 330 | def test_only_downloads_up_to_a_maximum_length(get_mock): 331 | content = Mock() 332 | max_length = 1 333 | _mock_content(get_mock, content=content, content_length=max_length) 334 | ret = download.download(EXAMPLE_COM, max_content_length=max_length) 335 | get_mock.return_value.iter_content.assert_called_with(max_length) 336 | assert ret.content == content 337 | 338 | def test_decodes_text_content(get_mock): 339 | content, encoding = Mock(), Mock() 340 | content.decode.return_value = 'text' 341 | _mock_content(get_mock, content=content, content_type='text/html', encoding=encoding) 342 | ret = download.download(EXAMPLE_COM) 343 | content.decode.assert_called_with(encoding, errors='ignore') 344 | assert 'text' == ret.text 345 | 346 | def test_guess_encoding_from_content(get_mock): 347 | content = 'fübar' 348 | _mock_content(get_mock, content=content.encode('utf-8'), content_type='text/html', encoding='latin1') 349 | ret = download.download(EXAMPLE_COM) 350 | assert content == ret.text 351 | 352 | def test_ignores_invalid_decode(get_mock): 353 | content, encoding = "üöä".encode('utf-8'), 'ascii' 354 | _mock_content(get_mock, content=content, content_type='text/html', encoding=encoding) 355 | ret = download.download(EXAMPLE_COM) 356 | # expect the empty fallback text because the decode had only errors 357 | assert '' == ret.text 358 | 359 | def test_only_decodes_text_content(get_mock): 360 | content = Mock() 361 | _mock_content(get_mock, content=content, content_type="something/else") 362 | ret = download.download(EXAMPLE_COM) 363 | # expect the empty fallback text because the decode failed 364 | assert None == ret.text 365 | 366 | def test_can_handle_empty_content(get_mock): 367 | return_mock = Mock(headers={'content-type': 'text/html'}) 368 | return_mock.iter_content.return_value = iter([]) 369 | get_mock.return_value = return_mock 370 | 371 | ret = download.download(EXAMPLE_COM) 372 | # expect the empty fallback text because we couldn't download content 373 | assert None == ret.text 374 | 375 | 376 | def test_can_list_all_items(api_client, api_user): 377 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=api_user) 378 | item2 = Item.objects.create(url='something.local', title='nothing', owner=api_user) 379 | response = api_client.get('/api/items/') 380 | response.data[0].pop('id') 381 | response.data[1].pop('id') 382 | assert list(map(dict, response.data)) == [ 383 | {'url': 'something.local', 'title': 'nothing', 384 | 'created': item2.created_as_str, 'readable_article': '', 'tags': []}, 385 | {'url': 'http://www.example.com/', 'title': 'nothing', 386 | 'created': item.created_as_str, 'readable_article': '', 'tags': []}, 387 | ] 388 | 389 | def test_can_update_item(api_client, api_user): 390 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=api_user) 391 | response = api_client.put('/api/items/{}/'.format(item.id), 392 | {'url': item.url, 'tags': ['test-tag', 'second-tag']}, 393 | format='json') 394 | assert response.data['id'] == item.id 395 | assert set(response.data['tags']) == {'test-tag', 'second-tag'} 396 | 397 | def test_can_patch_item(api_client, api_user): 398 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=api_user) 399 | response = api_client.patch('/api/items/{}/'.format(item.id), 400 | {'title': 'test'}, 401 | format='json') 402 | assert response.data['id'] == item.id 403 | assert response.data['title'] == 'test' 404 | 405 | def test_can_add_item_tags(api_client, api_user): 406 | item = add_example_item(api_user, ['foo']) 407 | response = api_client.patch('/api/items/{}/'.format(item.id), 408 | {'tags': ['foo', 'bar']}, 409 | format='json') 410 | assert response.data['id'] == item.id 411 | assert set(response.data['tags']) == {'foo', 'bar'} 412 | updated = Item.objects.get(pk=item.id) 413 | assert set(updated.tags.names()) == {'foo', 'bar'} 414 | 415 | def test_can_patch_item_tags(api_client, api_user): 416 | item = add_example_item(api_user, ['foo', 'bar']) 417 | response = api_client.patch('/api/items/{}/'.format(item.id), 418 | {'tags': ['foo']}, 419 | format='json') 420 | assert response.data['id'] == item.id 421 | assert response.data['tags'] == ['foo'] 422 | updated = Item.objects.get(pk=item.id) 423 | assert set(updated.tags.names()) == {'foo'} 424 | 425 | def test_can_clear_item_tags(api_client, api_user): 426 | item = Item.objects.create(url=EXAMPLE_COM, title='nothing', owner=api_user) 427 | response = api_client.patch('/api/items/{}/'.format(item.id), 428 | {'tags': []}, 429 | format='json') 430 | assert response.data['id'] == item.id 431 | assert response.data['tags'] == [] 432 | updated = Item.objects.get(pk=item.id) 433 | assert set(updated.tags.names()) == set() 434 | 435 | def test_items_are_searchable(api_client, api_user): 436 | response = api_client.post('/api/items/', {'url': EXAMPLE_COM, 'tags': ['test-tag', 'second-tag']}, 437 | format='json') 438 | assert 'id' in response.data 439 | sqs = SearchQuerySet().filter(owner_id=api_user.id).auto_query('second-tag') 440 | assert sqs.count() == 1, 'New item is not in the searchable by tag' 441 | 442 | def test_item_form_allows_tags_with_spaces(): 443 | form = CreateItemForm({'url': EXAMPLE_COM, 'tags': 'i have spaces, foo'}, instance=Item()) 444 | assert form.is_valid() 445 | cleaned = form.clean() 446 | assert cleaned['tags'] == ['foo', 'i have spaces'] 447 | -------------------------------------------------------------------------------- /readme/views.py: -------------------------------------------------------------------------------- 1 | from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage 2 | from django.template.response import TemplateResponse 3 | from django.views import generic 4 | from django.conf import settings 5 | from django.views.decorators.csrf import ensure_csrf_cookie 6 | from haystack.query import SearchQuerySet 7 | from haystack.views import FacetedSearchView, search_view_factory 8 | from sitegate.models import InvitationCode 9 | from sitegate.signup_flows.modern import InvitationSignup 10 | from .models import Item, UserProfile 11 | from .forms import CreateItemForm, UpdateItemForm, UserProfileForm, SearchForm 12 | from django.http import HttpResponseRedirect 13 | from django.core.urlresolvers import reverse_lazy, reverse 14 | from django.shortcuts import redirect, render_to_response 15 | from django.contrib.auth.decorators import login_required 16 | from django.utils.decorators import method_decorator 17 | from sitegate.decorators import signup_view, signin_view 18 | import json 19 | 20 | 21 | class LoginRequiredMixin(object): 22 | """ 23 | Mixing for generic views 24 | 25 | It applies the login_required decorator 26 | """ 27 | @method_decorator(login_required) 28 | def dispatch(self, request, *args, **kwargs): 29 | return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) 30 | 31 | 32 | class RestrictItemAccessMixin(object): 33 | """ 34 | Mixing for generic views that implement get_object() 35 | 36 | Restricts access so that every user can only access views for 37 | objects that have him as the object.owner 38 | """ 39 | def dispatch(self, request, *args, **kwargs): 40 | if not request.user == self.get_object().owner: 41 | return redirect('index') 42 | return super(RestrictItemAccessMixin, self).dispatch(request, *args, **kwargs) 43 | 44 | 45 | @login_required 46 | @ensure_csrf_cookie 47 | def index(request): 48 | profile = request.user.userprofile 49 | 50 | queryset = Item.objects.filter(owner=request.user).order_by('-created').prefetch_related('tags') 51 | sqs = SearchQuerySet().filter(owner_id=request.user.id) 52 | 53 | if not profile.show_excluded: 54 | excluded_tags = profile.excluded_tags.names() 55 | queryset = queryset.without(*excluded_tags) 56 | for tag in excluded_tags: 57 | sqs = sqs.exclude(tags__in=[tag]) 58 | 59 | facets = sqs.facet('tags').facet_counts() 60 | tag_objects = [] 61 | for name, count in facets.get('fields', {}).get('tags', []): 62 | if name is not None: 63 | tag_objects.append(Tag(name, count, [])) 64 | 65 | paginator = Paginator(queryset, profile.items_per_page) 66 | try: 67 | page = paginator.page(request.GET.get('page')) 68 | except PageNotAnInteger: 69 | page = paginator.page(1) 70 | except EmptyPage: 71 | page = paginator.page(paginator.num_pages) 72 | context = { 73 | 'item_list': queryset, 74 | 'tags': tag_objects, 75 | 'tag_names': json.dumps([tag.name for tag in tag_objects]), 76 | 'current_item_list': page, 77 | 'user': request.user, 78 | } 79 | return TemplateResponse(request, 'readme/item_list.html', context) 80 | 81 | 82 | class Tag: 83 | def __init__(self, name, count, tag_list): 84 | self.name = name 85 | self.count = count 86 | self.tag_list = tag_list[:] 87 | if not name in self.tag_list: 88 | self.tag_list.append(name) 89 | self.url = reverse('tags', kwargs={'tags': ','.join(self.tag_list)}) 90 | 91 | def as_tuple(self): 92 | return self.name, self.count, self.url 93 | 94 | def __eq__(self, other): 95 | return self.as_tuple() == other.as_tuple() 96 | 97 | def __hash__(self): 98 | return hash(self.as_tuple()) 99 | 100 | 101 | @login_required 102 | def tags(request, tags=''): 103 | if tags == '': 104 | return redirect(reverse('index')) 105 | tag_list = [tag for tag in tags.split(',') if tag != ''] 106 | 107 | # Due to a bug (or feature?) in Whoosh or haystack, we can't filter for all tags at once, 108 | # the .filter(tags=[...]) method cannot handle spaces apparently 109 | # It however works with tags__in, but that is an OR 110 | sqs = SearchQuerySet().filter(owner_id=request.user.id) 111 | for tag in tag_list: 112 | sqs = sqs.filter(tags__in=[tag]) 113 | 114 | profile = request.user.userprofile 115 | 116 | if not profile.show_excluded: 117 | excluded_tags = profile.excluded_tags.names() 118 | for tag in excluded_tags: 119 | sqs = sqs.exclude(tags__in=[tag]) 120 | 121 | sqs = sqs.order_by('-created').facet('tags') 122 | 123 | facets = sqs.facet_counts() 124 | result_objects = [result.object for result in sqs] 125 | tag_objects = [Tag(name, count, tag_list) for name, count in facets.get('fields', {}).get('tags', [])] 126 | return TemplateResponse(request, 'readme/item_list.html', { 127 | 'current_item_list': result_objects, 128 | 'tags': tag_objects, 129 | 'tag_names': json.dumps([tag.name for tag in tag_objects]), 130 | 'user': request.user, 131 | }) 132 | 133 | 134 | class TagNamesToContextMixin: 135 | 136 | def get_context_data(self, **kwargs): 137 | context = super(TagNamesToContextMixin, self).get_context_data(**kwargs) 138 | 139 | sqs = SearchQuerySet().filter(owner_id=self.request.user.id).facet('tags') 140 | 141 | facets = sqs.facet_counts() 142 | tags = [name for name, count in facets.get('fields', {}).get('tags', []) if name is not None] 143 | context['tag_names'] = json.dumps(tags) 144 | return context 145 | 146 | 147 | class UpdateItemView(TagNamesToContextMixin, RestrictItemAccessMixin, generic.UpdateView): 148 | model = Item 149 | context_object_name = 'item' 150 | success_url = reverse_lazy('index') 151 | 152 | form_class = UpdateItemForm 153 | 154 | def form_valid(self, form): 155 | self.object = form.save() 156 | self.object.save() 157 | return HttpResponseRedirect(self.get_success_url()) 158 | 159 | 160 | class UpdateUserProfileView(TagNamesToContextMixin, LoginRequiredMixin, generic.UpdateView): 161 | model = UserProfile 162 | context_object_name = 'user_profile' 163 | template_name = 'readme/profile.html' 164 | 165 | form_class = UserProfileForm 166 | 167 | success_url = reverse_lazy('profile') 168 | 169 | def get_object(self, queryset=None): 170 | return self.request.user.userprofile 171 | 172 | def form_valid(self, form): 173 | self.object = form.save() 174 | self.object.save() 175 | return HttpResponseRedirect(self.get_success_url()) 176 | 177 | 178 | class AddView(TagNamesToContextMixin, LoginRequiredMixin, generic.CreateView): 179 | model = Item 180 | form_class = CreateItemForm 181 | 182 | def get_success_url(self): 183 | return reverse('index') 184 | 185 | 186 | def form_valid(self, form): 187 | self.object = form.save(commit=False) 188 | 189 | duplicates = Item.objects.filter(owner=self.request.user, url=self.object.url) 190 | if duplicates.count(): 191 | duplicate = duplicates[0] 192 | duplicate.tags.add(*form.cleaned_data["tags"]) 193 | # additional save to update the search index 194 | duplicate.save() 195 | return HttpResponseRedirect(duplicate.get_absolute_url()) 196 | self.object.owner = self.request.user 197 | self.object.fetch_article() 198 | self.object.save() 199 | form.save_m2m() 200 | # additional save to update the search index 201 | self.object.save() 202 | return HttpResponseRedirect(self.get_success_url()) 203 | 204 | def get_initial(self): 205 | url = self.request.GET.get('url', None) 206 | return {'url': url} 207 | 208 | 209 | class ItemView(RestrictItemAccessMixin, generic.DetailView): 210 | model = Item 211 | context_object_name = 'item' 212 | 213 | 214 | class ItemSearchView(FacetedSearchView): 215 | """ 216 | SearchView that passes a dynamic SearchQuerySet to the 217 | form that restricts the result to those owned by 218 | the current user. 219 | """ 220 | 221 | def build_form(self, form_kwargs=None): 222 | user_id = self.request.user.id 223 | self.searchqueryset = SearchQuerySet().filter(owner_id=user_id).facet('tags') 224 | 225 | profile = self.request.user.userprofile 226 | if not profile.show_excluded: 227 | for tag in profile.excluded_tags.names(): 228 | self.searchqueryset = self.searchqueryset.exclude(tags__in=[tag]) 229 | 230 | sorting = self.request.GET.get('sort', '') 231 | if sorting == 'newest': 232 | self.searchqueryset = self.searchqueryset.order_by('-created') 233 | elif sorting == 'oldest': 234 | self.searchqueryset = self.searchqueryset.order_by('created') 235 | 236 | return super(ItemSearchView, self).build_form(form_kwargs) 237 | 238 | search = login_required(search_view_factory( 239 | view_class=ItemSearchView, 240 | form_class=SearchForm, 241 | )) 242 | 243 | 244 | def test(request, test_name): 245 | if settings.DEBUG: 246 | return render_to_response('readme/tests/{}.html'.format(test_name)) 247 | else: 248 | return redirect(reverse('index')) 249 | 250 | 251 | @login_required 252 | def invite(request): 253 | if request.method == 'POST': 254 | invite_id = request.POST.get('id', None) 255 | if invite_id is not None: 256 | try: 257 | code = InvitationCode.objects.get(creator=request.user, id=invite_id, expired=False) 258 | except InvitationCode.DoesNotExist: 259 | # ignore invalid request 260 | pass 261 | else: 262 | code.delete() 263 | elif request.user.userprofile.can_invite: 264 | InvitationCode.add(request.user) 265 | 266 | codes = InvitationCode.objects.filter(creator=request.user) 267 | return TemplateResponse(request, 'readme/invite.html', {'codes': codes}) 268 | 269 | _entrance_widget_attrs = { 270 | "class": "form-control", 271 | 'placeholder': lambda f: f.label 272 | } 273 | 274 | @signup_view( 275 | widget_attrs=_entrance_widget_attrs, 276 | flow=InvitationSignup, 277 | template='form_bootstrap3') 278 | @signin_view( 279 | widget_attrs=_entrance_widget_attrs, 280 | template='readme/form_signin.html') 281 | def entrance(request): 282 | return TemplateResponse(request, 'entrance.html', { 283 | 'title': 'Sign in & Sign up', 284 | }) 285 | 286 | 287 | 288 | # Class based views as normal view function 289 | 290 | add = AddView.as_view() 291 | view = ItemView.as_view() 292 | update = UpdateItemView.as_view() 293 | profile = UpdateUserProfileView.as_view() 294 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.7.3 2 | Whoosh>=2.6 3 | django-crispy-forms==1.4 4 | django-filter==0.9.1 5 | django-taggit==0.12.2 6 | djangorestframework==3.0.0 7 | psycopg2==2.5.4 8 | pycrypto==2.6.1 9 | lxml==3.4.1 10 | requests==2.5.0 11 | tld==0.7.2 12 | selenium==2.44.0 13 | cssselect==0.9.1 14 | pytest==2.6.4 15 | pytest-django==2.7.0 16 | pytest-cov==1.8.1 17 | django-pipeline==1.4.2 18 | django-settings-context-processor==0.2 19 | django-admin-bootstrapped==2.3.1 20 | django-sitegate==0.9 21 | bleach==1.4 22 | -e git+https://github.com/audax/python-readability.git@master#egg=python-readability 23 | -e git+https://github.com/audax/django-haystack.git@whoosh-basic-facets#egg=django-haystack 24 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ "$1" == "js" ]; then 3 | TESTS=( setup ) 4 | python manage.py runserver & 5 | sleep 3 6 | SERVER=$! 7 | success=0 8 | for test in "${TESTS[@]}"; do 9 | phantomjs bower_components/qunit-phantomjs-runner/runner.js http://localhost:8000/test/$test || success=$? 10 | done; 11 | kill $SERVER 12 | exit $success 13 | else 14 | exec py.test --cov-config .coveragerc --cov readme 15 | fi 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup(name='pypo', version='0.3') 4 | --------------------------------------------------------------------------------