├── .coveragerc ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .python-version ├── AUTHORS ├── CHANGES ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Procfile ├── Procfile.dev ├── README.rst ├── docker-compose.yml ├── docker ├── Procfile ├── entrypoint.sh └── uwsgi.ini ├── docs ├── Makefile ├── _build │ └── .gitignore ├── _static │ └── .gitkeep ├── _templates │ └── .gitkeep ├── adding_users.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── how_it_works.rst ├── index.rst ├── installing.rst └── settings.rst ├── manage.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── localshop │ ├── __init__.py │ ├── apps │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── auth_urls.py │ │ ├── forms.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_migrate_users.py │ │ │ ├── 0003_auto_20171116_2112.py │ │ │ ├── 0004_auto_20200612_1204.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── urls.py │ │ └── views.py │ ├── dashboard │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── urls.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── cidr.py │ │ │ ├── credentials.py │ │ │ ├── misc.py │ │ │ ├── package.py │ │ │ └── repository.py │ ├── packages │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── forms.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_repository.py │ │ │ ├── 0003_default_repo.py │ │ │ ├── 0004_auto_20150517_1612.py │ │ │ ├── 0005_auto_20150525_1931.py │ │ │ ├── 0006_repository_upstream_pypi_url.py │ │ │ ├── 0007_auto_20150909_2245.py │ │ │ ├── 0008_auto_20171116_2112.py │ │ │ ├── 0009_package_name.py │ │ │ ├── 0010_auto_20200612_1204.py │ │ │ └── __init__.py │ │ ├── mixins.py │ │ ├── models.py │ │ ├── pypi.py │ │ ├── tasks.py │ │ ├── urls.py │ │ ├── utils.py │ │ ├── views.py │ │ └── xmlrpc.py │ └── permissions │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0001_squashed_0002_remove_userena.py │ │ ├── 0002_auto_20150517_1857.py │ │ ├── 0002_remove_userena.py │ │ ├── 0003_auto_20171116_2112.py │ │ ├── 0004_auto_20200612_1204.py │ │ └── __init__.py │ │ ├── mixins.py │ │ ├── models.py │ │ └── utils.py │ ├── celery.py │ ├── http.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── create_default_user.py │ │ ├── init.py │ │ └── repository_refresh.py │ ├── runner.py │ ├── settings │ ├── __init__.py │ └── defaults.py │ ├── static │ └── localshop │ │ ├── css │ │ ├── main.css │ │ └── main.css.map │ │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ │ ├── js │ │ ├── bootstrap.min.js │ │ └── jquery-2.1.4.min.js │ │ └── less │ │ ├── bootstrap │ │ ├── alerts.less │ │ ├── badges.less │ │ ├── bootstrap.less │ │ ├── breadcrumbs.less │ │ ├── button-groups.less │ │ ├── buttons.less │ │ ├── carousel.less │ │ ├── close.less │ │ ├── code.less │ │ ├── component-animations.less │ │ ├── dropdowns.less │ │ ├── forms.less │ │ ├── glyphicons.less │ │ ├── grid.less │ │ ├── input-groups.less │ │ ├── jumbotron.less │ │ ├── labels.less │ │ ├── list-group.less │ │ ├── media.less │ │ ├── mixins.less │ │ ├── mixins │ │ │ ├── alerts.less │ │ │ ├── background-variant.less │ │ │ ├── border-radius.less │ │ │ ├── buttons.less │ │ │ ├── center-block.less │ │ │ ├── clearfix.less │ │ │ ├── forms.less │ │ │ ├── gradients.less │ │ │ ├── grid-framework.less │ │ │ ├── grid.less │ │ │ ├── hide-text.less │ │ │ ├── image.less │ │ │ ├── labels.less │ │ │ ├── list-group.less │ │ │ ├── nav-divider.less │ │ │ ├── nav-vertical-align.less │ │ │ ├── opacity.less │ │ │ ├── pagination.less │ │ │ ├── panels.less │ │ │ ├── progress-bar.less │ │ │ ├── reset-filter.less │ │ │ ├── resize.less │ │ │ ├── responsive-visibility.less │ │ │ ├── size.less │ │ │ ├── tab-focus.less │ │ │ ├── table-row.less │ │ │ ├── text-emphasis.less │ │ │ ├── text-overflow.less │ │ │ └── vendor-prefixes.less │ │ ├── modals.less │ │ ├── navbar.less │ │ ├── navs.less │ │ ├── normalize.less │ │ ├── pager.less │ │ ├── pagination.less │ │ ├── panels.less │ │ ├── popovers.less │ │ ├── print.less │ │ ├── progress-bars.less │ │ ├── responsive-embed.less │ │ ├── responsive-utilities.less │ │ ├── scaffolding.less │ │ ├── tables.less │ │ ├── theme.less │ │ ├── thumbnails.less │ │ ├── tooltip.less │ │ ├── type.less │ │ ├── utilities.less │ │ ├── variables.less │ │ └── wells.less │ │ ├── custom.less │ │ └── main.less │ ├── templates │ ├── 403.html │ ├── 404.html │ ├── 500.html │ ├── accounts │ │ ├── accesskey_confirm_delete.html │ │ ├── accesskey_form.html │ │ ├── accesskey_list.html │ │ ├── base.html │ │ ├── profile.html │ │ ├── team_confirm_delete.html │ │ ├── team_detail.html │ │ ├── team_form.html │ │ └── team_list.html │ ├── dashboard │ │ ├── index.html │ │ ├── package_detail.html │ │ ├── repository_create.html │ │ ├── repository_detail.html │ │ └── repository_settings │ │ │ ├── base.html │ │ │ ├── cidr_confirm_delete.html │ │ │ ├── cidr_form.html │ │ │ ├── cidr_list.html │ │ │ ├── credential_confirm_delete.html │ │ │ ├── credential_form.html │ │ │ ├── credential_list.html │ │ │ ├── delete.html │ │ │ ├── edit.html │ │ │ └── teams.html │ ├── layout.base.html │ ├── layout.main.html │ ├── packages │ │ ├── package_list.html │ │ ├── simple_package_detail.html │ │ └── simple_package_list.html │ ├── partial │ │ ├── _form_field.html │ │ └── _form_fields.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_form.html │ ├── templatetags │ ├── __init__.py │ ├── forms.py │ └── permission_tags.py │ ├── urls.py │ ├── utils.py │ ├── views.py │ └── wsgi.py ├── tests ├── __init__.py ├── apps │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ └── test_forms.py │ ├── dashboard │ │ ├── __init__.py │ │ └── test_forms.py │ ├── packages │ │ ├── __init__.py │ │ ├── test_fetch_package_task.py │ │ ├── test_models.py │ │ ├── test_pypi.py │ │ ├── test_tasks.py │ │ ├── test_utils.py │ │ ├── test_xmlrpc.py │ │ └── views │ │ │ ├── test_download_file.py │ │ │ ├── test_simple_detail.py │ │ │ └── test_simple_index.py │ └── permissions │ │ ├── __init__.py │ │ ├── test_models.py │ │ └── test_utils.py ├── conftest.py ├── factories.py ├── management │ ├── __init__.py │ ├── test_command_init.py │ └── test_runner.py ├── pypi_data │ ├── minibar.json │ └── pyramid_debugtoolbar.json └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = localshop 3 | omit = *migrations*, localshop/settings.py 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | if __name__ == .__main__.: 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | public/ 2 | .git/ 3 | .tox/ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.py] 4 | line_length = 79 5 | multi_line_output = 4 6 | balanced_wrapping = true 7 | default_section = THIRDPARTY 8 | known_first_party = localshop,tests 9 | use_parentheses = true 10 | indent_style = space 11 | indent_size = 4 12 | tab_width = 4 13 | 14 | [*.yml] 15 | indent_size = 2 16 | shift_width = 2 17 | 18 | [Makefile] 19 | indent_style = tab 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python: [3.6] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python }} 19 | - name: Install tox 20 | run: pip install --upgrade setuptools tox==3.15.0 21 | - name: Run tests 22 | run: tox -e py 23 | - name: Upload coverage to Codecov 24 | uses: codecov/codecov-action@v1 25 | with: 26 | file: .coverage 27 | fail_ci_if_error: true 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.egg 3 | /.tox 4 | /build/ 5 | /dist/ 6 | /source/ 7 | /public/ 8 | localshop.db 9 | *.pyc 10 | *.swp 11 | .cache 12 | /src/localshop/public/ 13 | 14 | # Virtualenv 15 | .venv 16 | 17 | # Coverage 18 | htmlcov 19 | .coverage 20 | coverage.xml 21 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.6.10 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | - Michael van Tellingen 2 | - Cesar Canassa 3 | 4 | And all contributors: 5 | - https://github.com/jazzband/localshop/graphs/contributors 6 | 7 | Contains code from: 8 | - Sentry https://github.com/dcramer/sentry/contributors 9 | - chishop https://github.com/ask/chishop/contributors 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | 5 | Please see the 6 | [full contributing documentation](https://localshop.readthedocs.io/en/latest/contributing.html) 7 | for more help. 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.6 2 | 3 | MAINTAINER Michael van Tellingen 4 | ENV LOCALSHOP_ROOT /home/localshop/data/ 5 | ENV STATIC_ROOT /home/localshop/static/ 6 | 7 | 8 | # Add user so that we run as non-root 9 | RUN addgroup -S localshop && adduser -S -D -G localshop localshop 10 | RUN mkdir -p /home/localshop && chown localshop:localshop /home/localshop 11 | 12 | RUN apk update 13 | RUN apk add \ 14 | gcc \ 15 | gettext \ 16 | imagemagick-dev \ 17 | jpeg \ 18 | jpeg-dev \ 19 | libc-dev \ 20 | linux-headers \ 21 | pcre-dev \ 22 | postgresql-dev \ 23 | python3 \ 24 | python3-dev \ 25 | redis \ 26 | zlib-dev \ 27 | && rm -rf /var/cache/apk/* 28 | 29 | RUN pip3 install honcho uwsgi==2.0.15 30 | 31 | ADD src /code/src/ 32 | ADD setup.py README.rst MANIFEST.in /code/ 33 | 34 | RUN cd /code/ && pip3 install . 35 | 36 | ADD ./docker/ /home/localshop/ 37 | 38 | USER localshop 39 | WORKDIR /home/localshop/ 40 | RUN mkdir /home/localshop/data/ 41 | RUN localshop collectstatic 42 | 43 | CMD /home/localshop/entrypoint.sh 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012-2017 Michael van Tellingen and individual contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude * 2 | recursive-exclude * * 3 | 4 | include CHANGES 5 | include LICENSE 6 | include MANIFEST.in 7 | include README.rst 8 | include manage.py 9 | include setup.cfg 10 | include setup.py 11 | 12 | graft src 13 | graft tests 14 | 15 | global-exclude __pycache__ 16 | global-exclude *.py[co] 17 | global-exclude .DS_Store 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN = .venv/bin 2 | 3 | .PHONY: install 4 | install: 5 | pyenv install -s 6 | pyenv local 7 | python -m venv .venv 8 | $(BIN)/pip install -e .[test] 9 | 10 | .PHONY: clean 11 | clean: 12 | find . -name '*.pyc' -delete 13 | find . -name '__pycache__' -delete 14 | 15 | .PHONY: test 16 | test: 17 | $(BIN)/pytest 18 | 19 | .PHONY: retest 20 | retest: 21 | $(BIN)/pytest --lf 22 | 23 | .PHONY: coverage 24 | coverage: 25 | $(BIN)/pytest --cov=localshop --cov-report=term-missing --nomigrations tests/ 26 | 27 | .PHONY: lint 28 | lint: 29 | $(BIN)/flake8 src/ tests/ 30 | 31 | .PHONY: css 32 | css: 33 | lessc --source-map --source-map-less-inline localshop/static/localshop/less/main.less localshop/static/localshop/css/main.css 34 | 35 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn localshop.wsgi --log-file - 2 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: python manage.py runserver 0.0.0.0:8000 2 | celery: python manage.py celery worker --no-execv -B -E --loglevel=debug 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | localshop 2 | ========= 3 | 4 | .. image:: https://img.shields.io/pypi/v/localshop.svg 5 | :target: https://pypi.python.org/pypi/localshop/ 6 | :alt: Latest Version 7 | 8 | .. image:: https://travis-ci.org/mvantellingen/localshop.svg?branch=master 9 | :target: https://travis-ci.org/mvantellingen/localshop 10 | 11 | .. image:: http://codecov.io/github/mvantellingen/localshop/coverage.svg?branch=master 12 | :target: http://codecov.io/github/mvantellingen/localshop?branch=master 13 | 14 | 15 | A PyPI server which automatically proxies and mirrors PyPI packages based 16 | upon packages requested. It has support for multiple indexes and team based 17 | access and also supports the uploading of local (private) packages. 18 | 19 | The full documentation is available on `Read The Docs`_ 20 | 21 | .. _`Read The Docs`: http://localshop.readthedocs.org/ 22 | 23 | 24 | 25 | Getting started 26 | --------------- 27 | 28 | When you want to host it on AWS with the Azure AD oauth2 server use: 29 | 30 | docker run \ 31 | -e DATABASE_URL=postgresql://user:password@host/database 32 | -e SECRET_KEY= 33 | -e LOCALSHOP_FILE_STORAGE=storages.backends.s3boto.S3BotoStorage 34 | -e LOCALSHOP_FILE_BUCKET_NAME= 35 | -e OAUTH2_PROVIDER=azuread-oauth2 \ 36 | -e OAUTH2_APPLICATION_ID= 37 | -e OAUTH2_SECRET_KEY= 38 | mvantellingen/localshop 39 | 40 | If you want more flexibility you can load your custom settings file by mounting 41 | a docker volume and creating a localshop.conf.py. This file will be loaded by 42 | localshop at the end of the settings file. 43 | 44 | docker run \ 45 | -e DATABASE_URL=postgresql://user:password@host/database 46 | -e SECRET_KEY= 47 | -v $(PWD)/config:/home/localshop/conf/ 48 | mvantellingen/localshop 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | db: 2 | image: sameersbn/postgresql:9.4 3 | environment: 4 | - DB_NAME=localshop 5 | - DB_USER=localshop 6 | - DB_PASS=shopping 7 | ports: 8 | - "5432" 9 | volumes: 10 | - ./docker-vol/postgresql:/var/lib/postgresql 11 | 12 | web: 13 | build: . 14 | volumes: 15 | - ./docker-vol:/opt/localshop 16 | links: 17 | - db:db 18 | environment: 19 | - DJANGO_SECRET_KEY=a7d03faa0c054b3dba38b396bf3c7996 20 | - DATABASE_URL=postgresql://localshop:shopping@db/localshop 21 | - DJANGO_MEDIA_ROOT=/localshop/media 22 | ports: 23 | - "8000:8000" 24 | command: uwsgi --http 0.0.0.0:8000 --module localshop.wsgi --master --die-on-term 25 | 26 | 27 | worker: 28 | build: . 29 | volumes: 30 | - ./docker-vol:/opt/localshop 31 | links: 32 | - db:db 33 | environment: 34 | - DJANGO_SECRET_KEY=a7d03faa0c054b3dba38b396bf3c7996 35 | - DATABASE_URL=postgresql://localshop:shopping@db/localshop 36 | - DJANGO_MEDIA_ROOT=/localshop/media 37 | entrypoint: 38 | localshop 39 | command: 40 | celery worker -B -E 41 | -------------------------------------------------------------------------------- /docker/Procfile: -------------------------------------------------------------------------------- 1 | redis: redis-server 2 | celery: celery -A localshop.celery worker -B -E --loglevel=info 3 | web: uwsgi --ini /home/localshop/uwsgi.ini 4 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | localshop init 4 | exec honcho start 5 | -------------------------------------------------------------------------------- /docker/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | processes = 2 3 | 4 | http = :8080 5 | http-enable-proxy-protocol = 1 6 | http-auto-chunked = true 7 | http-keepalive = 75 8 | http-timeout = 75 9 | 10 | # Handle docker stop 11 | die-on-term = 1 12 | 13 | threads = 5 14 | vacuum = 1 15 | master = true 16 | enable-threads = true 17 | lazy-apps = 1 18 | thunder-lock = 1 19 | buffer-size = 65535 20 | stats = /tmp/stats.socket 21 | post-buffering = true 22 | 23 | no-defer-accept = 1 24 | 25 | static-map = /static=$(STATIC_ROOT) 26 | 27 | 28 | # Kill requests after 30 seconds 29 | harakiri = 120 30 | harakiri-verbose = true 31 | 32 | module = localshop.wsgi:application 33 | 34 | log-x-forwarded-for = true 35 | 36 | # Redirect http -> https 37 | route-if = equal:${HTTP_X_FORWARDED_PROTO};http redirect-permanent:https://${HTTP_HOST}${REQUEST_URI} 38 | -------------------------------------------------------------------------------- /docs/_build/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/docs/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/adding_users.rst: -------------------------------------------------------------------------------- 1 | Adding users 2 | ============ 3 | 4 | You can add users using the Django admin backend at ``/admin``. After adding 5 | the user you need to assign the user to a team. Only teams can be assigned to 6 | repositories. 7 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ######### 3 | 4 | .. include:: ../CHANGES 5 | -------------------------------------------------------------------------------- /docs/how_it_works.rst: -------------------------------------------------------------------------------- 1 | How it works 2 | ============ 3 | 4 | Packages which are requested and are unknown are looked up on pypi via the 5 | xmlrpc interface. At the moment the client downloads one of the files which 6 | is not yet mirror'ed a 302 redirect is issued to the correct file (on pypi). 7 | At that point the worker starts downloading the package and stores it in 8 | ~/.localshop/files so that the next time the package is request it is 9 | available within your own shop! 10 | 11 | 12 | Uploading local/private packages 13 | -------------------------------- 14 | 15 | To upload your own packages to your shop you need to modify/create a .pypirc 16 | file. See the following example: 17 | 18 | .. code-block:: ini 19 | 20 | [distutils] 21 | index-servers = 22 | local 23 | 24 | [local] 25 | username: myusername 26 | password: mysecret 27 | repository: http://localhost:8000/repo/default/ 28 | 29 | To upload a custom package issue the following command in your package:: 30 | 31 | python setup.py upload -r local 32 | 33 | It should now be available via the webinterace 34 | 35 | 36 | Using the shop for package installation 37 | --------------------------------------- 38 | 39 | To install packages with pip from your localshop add `-i` flag, e.g.:: 40 | 41 | pip install -i http://localhost:8000/repo/default/ localshop 42 | 43 | or edit/create a ~/.pip/pip.conf file following this template: 44 | 45 | .. code-block:: ini 46 | 47 | [global] 48 | index-url = http://:@localhost:8000/repo/default/ 49 | 50 | Then just use pip install as you are used to do. 51 | You can replace access_key and secret_key by a valid username and password. 52 | 53 | Credentials for authentication 54 | ------------------------------ 55 | 56 | If you don't want to use your Django username/password to authenticate 57 | uploads and downloads you can easily create one of the random credentials 58 | localshop can create for you. 59 | 60 | Go to the Credentials section and click on create. Use the access key 61 | as the username and the secret key as the password when uploading packages. 62 | A ``~/.pypirc`` could look like this: 63 | 64 | .. code-block:: ini 65 | 66 | [distutils] 67 | index-servers = 68 | local 69 | 70 | [local] 71 | username: 4baf221849c84a20b77a6f2d539c3e8a 72 | password: 200984e70f0c463b994388c4da26ec3f 73 | repository: http://localhost:8000/simple/ 74 | 75 | pip allows you do use those values in the index URL during download, e.g.:: 76 | 77 | pip install -i http://:@localhost:8000/repo/default/ localshop 78 | 79 | So for example:: 80 | 81 | pip install -i http://4baf221849c84a20b77a6f2d539c3e8a:200984e70f0c463b994388c4da26ec3f@localhost:8000/repo/default/ localshop 82 | 83 | .. warning:: 84 | 85 | Please be aware that those credentials are transmitted unencrypted over 86 | http unless you setup your localshop instance to run on a server that 87 | serves pages via https. 88 | 89 | In case you ever think a credential has been compromised you can disable it 90 | or delete it on the credential page. 91 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Welcome to Localshop 3 | ====================== 4 | 5 | Localshop is a PyPI server which automatically proxies and mirrors PyPI 6 | packages based upon packages requested. It also supports the uploading of local 7 | (private) packages. 8 | 9 | 10 | Contents: 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | installing 16 | how_it_works 17 | adding_users 18 | settings 19 | contributing 20 | changelog 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/installing.rst: -------------------------------------------------------------------------------- 1 | .. _installation-instructions: 2 | 3 | Installing 4 | ========== 5 | 6 | Download and install localshop via the following command:: 7 | 8 | pip install localshop 9 | 10 | This should best be done in a new virtualenv. Now initialize your localshop 11 | environment by issuing the following command:: 12 | 13 | localshop init 14 | 15 | If you are upgrading from an earlier version simply run:: 16 | 17 | localshop upgrade 18 | 19 | And then start it via:: 20 | 21 | gunicorn localshop.wsgi:application 22 | 23 | You will also need to start the celery daemon, it's responsible for downloading 24 | and updating the packages from PyPI. So open another terminal, activate your 25 | virtualenv (if you have created one) and run the following command:: 26 | 27 | localshop celery worker -B -E 28 | 29 | You can now visit http://localhost:8000/ and view all the packages in your 30 | localshop! 31 | 32 | **Note:** If you prefer to start listening on a different network interface and 33 | HTTP port, you have the pass the parameter ``-b`` to ``gunicorn``. For example, 34 | the following command starts localshop on port 7000 instead of 8000:: 35 | 36 | gunicorn localshop.wsgi:application -b 0.0.0.0:7000 37 | 38 | The next step is to give access to various hosts to use the shop. This is done 39 | via the webinterface (menu -> permissions -> cidr). Each ip address listed there 40 | will be able to download and upload packages. If you are unsure about ips 41 | configuration, but still want to use authentication, specify "0.0.0.0/0" as the 42 | unique cidr configuration. It will enable for any ip address. 43 | 44 | 45 | Docker alternative 46 | ------------------ 47 | Install docker and docker-compose and then run: 48 | 49 | .. code-block:: bash 50 | 51 | cp docker.conf.py{.example,} 52 | docker-compose build 53 | docker-compose run localshop syncdb 54 | docker-compose run localshop createsuperuser 55 | docker-compose up 56 | 57 | You should be able to see localshop running in `http://docker-host:8000`. 58 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | There are a few settings to set in ``~/.localshop/localshop.conf.py`` that 5 | change the behaviour of the localshop. 6 | 7 | ``LOCALSHOP_DELETE_FILES`` 8 | -------------------------- 9 | 10 | :default: ``False`` 11 | 12 | If set to ``True`` files will be cleaned up after deleting a package or 13 | release from the localshop. 14 | 15 | ``LOCALSHOP_DISTRIBUTION_STORAGE`` 16 | ---------------------------------- 17 | 18 | :default: ``'storages.backends.overwrite.OverwriteStorage'`` 19 | 20 | The dotted import path of a Django storage class to be used when uploading 21 | a release file or retrieving it from PyPI. 22 | 23 | ``LOCALSHOP_HTTP_PROXY`` 24 | ------------------------ 25 | 26 | :default: ``None`` 27 | 28 | Proxy configuration used for Internet access. Expects a dictionary configured 29 | as mentioned by 30 | http://docs.python-requests.org/en/latest/user/advanced/#proxies 31 | 32 | ``LOCALSHOP_ISOLATED`` 33 | ---------------------- 34 | 35 | :default: ``False`` 36 | 37 | If set to ``True`` Localshop never will try to redirect the client to PyPI. 38 | This is useful for environments where the client has no Internet connection. 39 | 40 | .. note:: 41 | If you set ``LOCALSHOP_ISOLATED`` to ``True``, client request can be delayed 42 | for a long time because the package must be downloaded from Internet before 43 | it is served. You may want to set pip environment variable 44 | ``PIP_DEFAULT_TIMEOUT`` to a big value. Ex: ``300`` 45 | 46 | ``LOCALSHOP_USE_PROXIED_IP`` 47 | ---------------------------- 48 | 49 | :default: ``False`` 50 | 51 | If set to ``True`` Localshop will use the X-Forwarded-For header to validate 52 | the client IP address. Use this when Localshop is running behind a reverse 53 | proxy such as Nginx or Apache and you want to use IP-based permissions. 54 | 55 | ``LOCALSHOP_RELEASE_OVERWRITE`` 56 | ------------------------------- 57 | 58 | :default: ``True`` 59 | 60 | If set to ``False``, users will be prevented from overwriting already existing 61 | release files. Can be used to encourage developers to bump versions rather than 62 | overwriting. This is PyPI's behaviour. 63 | 64 | ``LOCALSHOP_VERSIONING_TYPE`` 65 | ------------------------------- 66 | 67 | :default: ``None`` 68 | 69 | If set to ``False``, no versioning "style" will be enforced. 70 | 71 | If you want to validated versions you can choose any `Versio `_ available backends. 72 | 73 | **IMPORTANT** the value of this config must be a full path of the wanted class e.g. `versio.version_scheme.Pep440VersionScheme`. 74 | 75 | - **Simple3VersionScheme** which supports 3 numerical part versions (A.B.C 76 | where A, B, and C are integers) 77 | - **Simple4VersionScheme** which supports 4 numerical part versions (A.B.C.D 78 | where A, B, C, and D are integers) 79 | - **Pep440VersionScheme** which supports `PEP 440 `_ 80 | versions (N[.N]+[{a|b|c|rc}N][.postN][.- devN][+local]) 81 | - **PerlVersionScheme** which supports 2 numerical part versions where the 82 | second part is at least two digits A.BB where A and B - are integers and B is 83 | zero padded on the left. For example: 1.02, 1.34, 1.567) 84 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from localshop.runner import main 3 | 4 | if __name__ == "__main__": 5 | main() 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | DJANGO_SETTINGS_MODULE = localshop.settings 3 | testpaths = tests/ 4 | django_find_project = false 5 | 6 | 7 | [flake8] 8 | max-line-length = 99 9 | exclude=src/**/migrations/*.py 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | readme = [] 4 | with open('README.rst', 'r') as fh: 5 | readme = fh.readlines() 6 | 7 | tests_require = [ 8 | 'django-webtest==1.9.7', 9 | 'factory-boy==2.12.0', 10 | 'mock==4.0.2', 11 | 'pytest-cov==2.9.0', 12 | 'pytest-django==3.9.0', 13 | 'pytest==5.4.3', 14 | 'requests-mock==1.8.0', 15 | 'requests-toolbelt==0.9.1', 16 | ] 17 | 18 | setup( 19 | name='localshop', 20 | version='2.0.0-alpha.1', 21 | author='Michael van Tellingen', 22 | author_email='michaelvantellingen@gmail.com', 23 | url='http://github.com/mvantellingen/localshop', 24 | description='A private pypi server including auto-mirroring of pypi.', 25 | long_description='\n'.join(readme), 26 | zip_safe=False, 27 | install_requires=[ 28 | 'boto3==1.14.1', 29 | 'celery==4.4.0', 30 | 'django-braces==1.14.0', 31 | 'django-celery-beat==2.0.0', 32 | 'django-celery-results==1.2.1', 33 | 'django-environ==0.4.5', 34 | 'django-model-utils==4.0.0', 35 | 'django-storages==1.9.1', 36 | 'django-widget-tweaks==1.4.8', 37 | 'Django==2.2.13', 38 | 'docutils==0.15.2', 39 | 'netaddr==0.7.19', 40 | 'Pillow==7.1.2', 41 | 'psycopg2-binary==2.8.5', 42 | 'redis==3.5.3', 43 | 'requests==2.23.0', 44 | 'social-auth-app-django==3.4.0', 45 | 'sqlparse==0.3.1', 46 | 'Versio==0.4.0', 47 | ], 48 | tests_require=tests_require, 49 | extras_require={'test': tests_require}, 50 | license='BSD', 51 | include_package_data=True, 52 | package_dir={'': 'src'}, 53 | packages=find_packages('src'), 54 | entry_points={ 55 | 'console_scripts': [ 56 | 'localshop = localshop.runner:main' 57 | ] 58 | }, 59 | classifiers=[ 60 | 'Development Status :: 5 - Production/Stable', 61 | 'Framework :: Django', 62 | 'Intended Audience :: Developers', 63 | 'Intended Audience :: System Administrators', 64 | 'Operating System :: OS Independent', 65 | 'Topic :: Software Development', 66 | 'Topic :: System', 67 | 'Topic :: System :: Software Distribution', 68 | 'Programming Language :: Python :: 2', 69 | 'Programming Language :: Python :: 2.6', 70 | 'Programming Language :: Python :: 2.7', 71 | 'Programming Language :: Python :: 3', 72 | 'Programming Language :: Python :: 3.3', 73 | 'Programming Language :: Python :: 3.4', 74 | 'Programming Language :: Python :: 3.5', 75 | 'Programming Language :: Python :: 3.6', 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /src/localshop/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = __version__ = '0.9.2' 2 | -------------------------------------------------------------------------------- /src/localshop/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/accounts/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/accounts/auth_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import url 3 | from django.contrib.auth.views import PasswordResetView, PasswordResetDoneView, PasswordChangeDoneView, \ 4 | PasswordChangeView, PasswordResetConfirmView, PasswordResetCompleteView, LogoutView 5 | from django.urls import reverse_lazy 6 | from django.views.generic.base import RedirectView 7 | 8 | from localshop.apps.accounts.views import login as login_view 9 | 10 | urlpatterns = [ 11 | url(r'^logout/$', LogoutView.as_view(), name='logout'), 12 | url(r'^password_change/$', PasswordChangeView.as_view(), name='password_change'), 13 | url(r'^password_change/done/$', PasswordChangeDoneView.as_view(), name='password_change_done'), 14 | url(r'^password_reset/$', PasswordResetView.as_view(), name='password_reset'), 15 | url(r'^password_reset/done/$', PasswordResetDoneView.as_view(), name='password_reset_done'), 16 | url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 17 | PasswordResetConfirmView.as_view(), name='password_reset_confirm'), 18 | url(r'^reset/done/$', PasswordResetCompleteView.as_view(), name='password_reset_complete'), 19 | ] 20 | 21 | if settings.OAUTH2_PROVIDER: 22 | urlpatterns.append( 23 | url(r'^login/$', RedirectView.as_view( 24 | url=reverse_lazy('social:begin', kwargs={ 25 | 'backend': settings.OAUTH2_PROVIDER 26 | }), 27 | ), name='login') 28 | ) 29 | else: 30 | urlpatterns.append(url(r'^login/$', login_view, name='login')) 31 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/forms.py: -------------------------------------------------------------------------------- 1 | from braces.forms import UserKwargModelFormMixin 2 | from django import forms 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.forms import ( 5 | AuthenticationForm as BaseAuthenticationForm) 6 | 7 | from localshop.apps.accounts import models 8 | 9 | 10 | class AuthenticationForm(BaseAuthenticationForm): 11 | remember_me = forms.BooleanField(required=False) 12 | 13 | 14 | class ProfileForm(forms.ModelForm): 15 | class Meta: 16 | model = get_user_model() 17 | fields = ['username', 'first_name', 'last_name', 'email'] 18 | 19 | 20 | class AccessKeyForm(UserKwargModelFormMixin, forms.ModelForm): 21 | 22 | class Meta: 23 | model = models.AccessKey 24 | fields = ['comment'] 25 | 26 | def save(self, commit=True): 27 | self.instance.user = self.user 28 | return super().save(commit=commit) 29 | 30 | 31 | class TeamFormMixin(object): 32 | def __init__(self, *args, **kwargs): 33 | self.team = kwargs.pop('team') 34 | super().__init__(*args, **kwargs) 35 | 36 | 37 | class TeamMemberAddForm(TeamFormMixin, forms.ModelForm): 38 | class Meta: 39 | model = models.TeamMember 40 | fields = ['user', 'role'] 41 | 42 | def clean_user(self): 43 | user = self.cleaned_data['user'] 44 | if user and self.team.members.filter(user=user).exists(): 45 | raise forms.ValidationError( 46 | "%s is already a member of the team" % user.username) 47 | return user 48 | 49 | def save(self, commit=True): 50 | instance = super().save(commit=False) 51 | if commit: 52 | instance.team = self.team 53 | instance.save() 54 | return instance 55 | 56 | 57 | class TeamMemberRemoveForm(TeamFormMixin, forms.Form): 58 | member_obj = forms.ModelChoiceField(models.TeamMember.objects.all()) 59 | 60 | def __init__(self, *args, **kwargs): 61 | super().__init__(*args, **kwargs) 62 | self.fields['member_obj'] = forms.ModelChoiceField( 63 | self.team.members.all()) 64 | 65 | def clean(self): 66 | member_obj = self.cleaned_data.get('member_obj') 67 | 68 | # This should never happen 69 | if not member_obj or member_obj.team.pk != self.team.pk: 70 | raise forms.ValidationError("Member is not part of the team") 71 | 72 | return self.cleaned_data 73 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/migrations/0002_migrate_users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | def forwards(apps, schema_editor): 8 | 9 | if 'auth_user' in schema_editor.connection.introspection.table_names(): 10 | cursor = schema_editor.connection.cursor() 11 | cursor.execute(""" 12 | INSERT INTO accounts_user 13 | SELECT * FROM auth_user WHERE id > 0 14 | """) 15 | 16 | User = apps.get_model('accounts', 'User') 17 | if User.objects.count() > 0: 18 | Team = apps.get_model('accounts', 'Team') 19 | Member = apps.get_model('accounts', 'TeamMember') 20 | 21 | team = Team.objects.create( 22 | name='Default', description='Auto created during migration') 23 | for user in User.objects.all(): 24 | Member.objects.create( 25 | team=team, user=user, 26 | role='owner' if user.is_superuser else 'developer') 27 | 28 | 29 | class Migration(migrations.Migration): 30 | 31 | dependencies = [ 32 | ('accounts', '0001_initial'), 33 | ] 34 | 35 | operations = [ 36 | migrations.RunPython(code=forwards), 37 | ] 38 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/migrations/0003_auto_20171116_2112.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-11-16 21:12 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | import django.contrib.auth.models 8 | import django.contrib.auth.validators 9 | from django.db import migrations, models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('accounts', '0002_migrate_users'), 16 | ] 17 | 18 | operations = [ 19 | migrations.AlterModelManagers( 20 | name='user', 21 | managers=[ 22 | ('objects', django.contrib.auth.models.UserManager()), 23 | ], 24 | ), 25 | migrations.AlterField( 26 | model_name='accesskey', 27 | name='access_key', 28 | field=models.UUIDField(db_index=True, default=uuid.uuid4, help_text='The access key', verbose_name='Access key'), 29 | ), 30 | migrations.AlterField( 31 | model_name='accesskey', 32 | name='comment', 33 | field=models.CharField(blank=True, default='', help_text="A comment about this credential, e.g. where it's being used", max_length=255, null=True), 34 | ), 35 | migrations.AlterField( 36 | model_name='accesskey', 37 | name='secret_key', 38 | field=models.UUIDField(db_index=True, default=uuid.uuid4, help_text='The secret key', verbose_name='Secret key'), 39 | ), 40 | migrations.AlterField( 41 | model_name='teammember', 42 | name='role', 43 | field=models.CharField(choices=[('owner', 'Owner'), ('developer', 'Developer')], max_length=100), 44 | ), 45 | migrations.AlterField( 46 | model_name='user', 47 | name='email', 48 | field=models.EmailField(blank=True, max_length=254, verbose_name='email address'), 49 | ), 50 | migrations.AlterField( 51 | model_name='user', 52 | name='groups', 53 | field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), 54 | ), 55 | migrations.AlterField( 56 | model_name='user', 57 | name='last_login', 58 | field=models.DateTimeField(blank=True, null=True, verbose_name='last login'), 59 | ), 60 | migrations.AlterField( 61 | model_name='user', 62 | name='username', 63 | field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/migrations/0004_auto_20200612_1204.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-06-12 12:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('accounts', '0003_auto_20171116_2112'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='last_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='last name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/accounts/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import AbstractUser 5 | from django.urls import reverse 6 | from django.db import models 7 | from django.utils.encoding import python_2_unicode_compatible 8 | from django.utils.translation import ugettext as _ 9 | from model_utils.fields import AutoCreatedField 10 | from model_utils.models import TimeStampedModel 11 | 12 | 13 | class User(AbstractUser): 14 | pass 15 | 16 | 17 | class AccessKeyQuerySet(models.QuerySet): 18 | 19 | def is_allowed(self, repository, access_key, secret_key): 20 | return ( 21 | self 22 | .filter( 23 | user__team_memberships__team__repositories=repository, 24 | access_key=access_key, 25 | secret_key=secret_key) 26 | .exists()) 27 | 28 | 29 | class AccessKey(models.Model): 30 | created = AutoCreatedField() 31 | 32 | user = models.ForeignKey( 33 | settings.AUTH_USER_MODEL, related_name='access_keys', on_delete=models.CASCADE) 34 | 35 | access_key = models.UUIDField( 36 | verbose_name='Access key', help_text='The access key', 37 | default=uuid.uuid4, db_index=True) 38 | secret_key = models.UUIDField( 39 | verbose_name='Secret key', help_text='The secret key', 40 | default=uuid.uuid4, db_index=True) 41 | comment = models.CharField( 42 | max_length=255, blank=True, null=True, default='', 43 | help_text=_( 44 | "A comment about this credential, e.g. where it's being used")) 45 | last_usage = models.DateTimeField(null=True, blank=True) 46 | 47 | objects = AccessKeyQuerySet.as_manager() 48 | 49 | class Meta: 50 | ordering = ['-created'] 51 | 52 | @property 53 | def allow_upload(self): 54 | return True 55 | 56 | 57 | @python_2_unicode_compatible 58 | class Team(TimeStampedModel): 59 | name = models.CharField(max_length=200) 60 | description = models.CharField(max_length=500, blank=True) 61 | users = models.ManyToManyField( 62 | settings.AUTH_USER_MODEL, through='TeamMember') 63 | 64 | def __str__(self): 65 | return self.name 66 | 67 | def get_absolute_url(self): 68 | return reverse('accounts:team_detail', kwargs={'pk': self.pk}) 69 | 70 | def owners(self): 71 | return [member.user for member in self.members.filter(role='owner')] 72 | 73 | 74 | class TeamMember(TimeStampedModel): 75 | team = models.ForeignKey(Team, related_name='members', on_delete=models.CASCADE) 76 | user = models.ForeignKey( 77 | settings.AUTH_USER_MODEL, related_name='team_memberships', on_delete=models.CASCADE) 78 | role = models.CharField(max_length=100, choices=[ 79 | ('owner', _("Owner")), 80 | ('developer', _("Developer")), 81 | ]) 82 | 83 | class Meta: 84 | unique_together = [ 85 | ('team', 'user'), 86 | ] 87 | 88 | @property 89 | def is_owner(self): 90 | return self.role == 'owner' 91 | -------------------------------------------------------------------------------- /src/localshop/apps/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | from localshop.apps.accounts import views 4 | 5 | app_name = 'accounts' 6 | 7 | urlpatterns = [ 8 | url(r'^profile/$', views.ProfileView.as_view(), name='profile'), 9 | url(r'^access-keys/$', views.AccessKeyListView.as_view(), name='access_key_list'), 10 | url(r'^access-keys/new$', views.AccessKeyCreateView.as_view(), name='access_key_create'), 11 | url(r'^access-keys/(?P\d+)/', include([ 12 | url(r'^secret$', views.AccessKeySecretView.as_view(), name='access_key_secret'), 13 | url(r'^edit$', views.AccessKeyUpdateView.as_view(), name='access_key_edit'), 14 | url(r'^delete$', views.AccessKeyDeleteView.as_view(), name='access_key_delete'), 15 | ])), 16 | 17 | url(r'^teams/$', views.TeamListView.as_view(), name='team_list'), 18 | url(r'^teams/create$', views.TeamCreateView.as_view(), name='team_create'), 19 | url(r'^teams/(?P\d+)/', include([ 20 | url(r'^$', views.TeamDetailView.as_view(), name='team_detail'), 21 | url(r'^edit$', views.TeamUpdateView.as_view(), name='team_edit'), 22 | url(r'^delete$', views.TeamDeleteView.as_view(), name='team_delete'), 23 | url(r'^member-add$', views.TeamMemberAddView.as_view(), name='team_member_add'), 24 | url(r'^member-remove$', views.TeamMemberRemoveView.as_view(), name='team_member_remove'), 25 | ])) 26 | ] 27 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/dashboard/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils import timezone 3 | 4 | from localshop.apps.accounts.models import Team 5 | from localshop.apps.packages.models import Repository 6 | from localshop.apps.permissions.models import CIDR, Credential 7 | 8 | 9 | class RepositoryFormMixin(object): 10 | def __init__(self, *args, **kwargs): 11 | self.repository = kwargs.pop('repository') 12 | super().__init__(*args, **kwargs) 13 | 14 | 15 | class AccessControlForm(RepositoryFormMixin, forms.ModelForm): 16 | 17 | class Meta: 18 | fields = ['label', 'cidr', 'require_credentials'] 19 | model = CIDR 20 | 21 | def save(self, commit=True): 22 | instance = super().save(commit=False) 23 | instance.repository = self.repository 24 | instance.save() 25 | return instance 26 | 27 | 28 | class RepositoryForm(forms.ModelForm): 29 | class Meta: 30 | model = Repository 31 | fields = [ 32 | 'name', 'slug', 'description', 'enable_auto_mirroring', 33 | 'upstream_pypi_url', 34 | ] 35 | 36 | 37 | class RepositoryTeamForm(RepositoryFormMixin, forms.Form): 38 | delete = forms.BooleanField(widget=forms.HiddenInput, required=False) 39 | team = forms.ModelChoiceField(Team.objects.all()) 40 | 41 | def __init__(self, *args, **kwargs): 42 | super().__init__(*args, **kwargs) 43 | 44 | if not self.data: 45 | self.fields['team'].queryset = ( 46 | self.fields['team'].queryset 47 | .exclude( 48 | pk__in=self.repository.teams.values_list('pk', flat=True))) 49 | 50 | def save(self): 51 | if self.cleaned_data['delete']: 52 | self.repository.teams.remove(self.cleaned_data['team']) 53 | else: 54 | self.repository.teams.add(self.cleaned_data['team']) 55 | 56 | 57 | class CredentialModelForm(RepositoryFormMixin, forms.ModelForm): 58 | deactivated = forms.BooleanField(required=False) 59 | 60 | class Meta: 61 | model = Credential 62 | fields = ('comment', 'allow_upload', 'deactivated') 63 | 64 | def clean_deactivated(self): 65 | value = self.cleaned_data['deactivated'] 66 | return timezone.now() if value else None 67 | 68 | def save(self, commit=True): 69 | instance = super().save(commit=False) 70 | 71 | if commit: 72 | instance.repository = self.repository 73 | instance.save() 74 | return instance 75 | 76 | 77 | class PackageAddForm(RepositoryFormMixin, forms.Form): 78 | name = forms.CharField(max_length=200) 79 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | from localshop.apps.dashboard import views 4 | 5 | app_name = 'dashboard' 6 | 7 | repository_urls = [ 8 | # Package urls 9 | url(r'^packages/add/$', 10 | views.PackageAddView.as_view(), 11 | name='package_add'), 12 | url(r'^packages/(?P[-._\w]+)/', include([ 13 | url(r'^$', 14 | views.PackageDetailView.as_view(), 15 | name='package_detail'), 16 | url(r'^refresh-from-upstream/$', 17 | views.PackageRefreshView.as_view(), 18 | name='package_refresh'), 19 | url(r'^release-mirror-file/$', 20 | views.PackageMirrorFileView.as_view(), 21 | name='package_mirror_file'), 22 | ])), 23 | 24 | # CIDR 25 | url(r'^settings/cidr/$', 26 | views.CidrListView.as_view(), name='cidr_index'), 27 | url(r'^settings/cidr/create$', 28 | views.CidrCreateView.as_view(), name='cidr_create'), 29 | url(r'^settings/cidr/(?P\d+)/edit', 30 | views.CidrUpdateView.as_view(), name='cidr_edit'), 31 | url(r'^settings/cidr/(?P\d+)/delete', 32 | views.CidrDeleteView.as_view(), name='cidr_delete'), 33 | 34 | # Credentials 35 | url(r'^settings/credentials/$', 36 | views.CredentialListView.as_view(), 37 | name='credential_index'), 38 | url(r'^settings/credentials/create$', 39 | views.CredentialCreateView.as_view(), 40 | name='credential_create'), 41 | url(r'^settings/credentials/(?P[-a-f0-9]+)/secret', 42 | views.CredentialSecretKeyView.as_view(), 43 | name='credential_secret'), 44 | url(r'^settings/credentials/(?P[-a-f0-9]+)/edit', 45 | views.CredentialUpdateView.as_view(), 46 | name='credential_edit'), 47 | url(r'^settings/credentials/(?P[-a-f0-9]+)/delete', 48 | views.CredentialDeleteView.as_view(), 49 | name='credential_delete'), 50 | 51 | url(r'^settings/teams/$', views.TeamAccessView.as_view(), name='team_access'), 52 | ] 53 | 54 | urlpatterns = [ 55 | url(r'^$', views.IndexView.as_view(), name='index'), 56 | url(r'^repositories/create$', views.RepositoryCreateView.as_view(), name='repository_create'), 57 | 58 | url(r'^repositories/(?P[^/]+)/', include([ 59 | url(r'^$', views.RepositoryDetailView.as_view(), name='repository_detail'), 60 | url(r'^edit$', views.RepositoryUpdateView.as_view(), name='repository_edit'), 61 | url(r'^delete$', views.RepositoryDeleteView.as_view(), name='repository_delete'), 62 | url(r'^refresh$', views.RepositoryRefreshView.as_view(), name='repository_refresh'), 63 | ])), 64 | 65 | url(r'^repositories/(?P[^/]+)/', include(repository_urls)) 66 | 67 | ] 68 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .cidr import * # noqa 2 | from .credentials import * # noqa 3 | from .misc import * # noqa 4 | from .package import * # noqa 5 | from .repository import * # noqa 6 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/views/cidr.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.views import generic 3 | 4 | from localshop.apps.dashboard import forms 5 | from localshop.apps.dashboard.views.repository import RepositoryMixin 6 | 7 | __all__ = [ 8 | 'CidrListView', 9 | 'CidrCreateView', 10 | 'CidrUpdateView', 11 | 'CidrDeleteView', 12 | ] 13 | 14 | 15 | class CidrListView(RepositoryMixin, generic.ListView): 16 | object_context_name = 'cidrs' 17 | template_name = 'dashboard/repository_settings/cidr_list.html' 18 | 19 | def get_queryset(self): 20 | return self.repository.cidr_list.all() 21 | 22 | 23 | class CidrCreateView(RepositoryMixin, generic.CreateView): 24 | form_class = forms.AccessControlForm 25 | template_name = 'dashboard/repository_settings/cidr_form.html' 26 | 27 | def get_success_url(self): 28 | return reverse('dashboard:cidr_index', kwargs={ 29 | 'repo': self.repository.slug, 30 | }) 31 | 32 | def get_queryset(self): 33 | return self.repository.cidr_list.all() 34 | 35 | 36 | class CidrUpdateView(RepositoryMixin, generic.UpdateView): 37 | form_class = forms.AccessControlForm 38 | template_name = 'dashboard/repository_settings/cidr_form.html' 39 | 40 | def get_success_url(self): 41 | return reverse('dashboard:cidr_index', kwargs={ 42 | 'repo': self.repository.slug, 43 | }) 44 | 45 | def get_queryset(self): 46 | return self.repository.cidr_list.all() 47 | 48 | 49 | class CidrDeleteView(RepositoryMixin, generic.DeleteView): 50 | form_class = forms.AccessControlForm 51 | template_name = 'dashboard/repository_settings/cidr_confirm_delete.html' 52 | 53 | def get_success_url(self): 54 | return reverse('dashboard:cidr_index', kwargs={ 55 | 'repo': self.repository.slug, 56 | }) 57 | 58 | def get_queryset(self): 59 | return self.repository.cidr_list.all() 60 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/views/credentials.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.models import Site 2 | from django.core.exceptions import SuspiciousOperation 3 | from django.urls import reverse 4 | from django.http import HttpResponse 5 | from django.shortcuts import get_object_or_404 6 | from django.views import generic 7 | 8 | from localshop.apps.dashboard import forms 9 | from localshop.apps.dashboard.views.repository import RepositoryMixin 10 | 11 | __all__ = [ 12 | 'CredentialListView', 13 | 'CredentialCreateView', 14 | 'CredentialSecretKeyView', 15 | 'CredentialUpdateView', 16 | 'CredentialDeleteView', 17 | ] 18 | 19 | 20 | class CredentialListView(RepositoryMixin, generic.ListView): 21 | object_context_name = 'credentials' 22 | template_name = 'dashboard/repository_settings/credential_list.html' 23 | 24 | def get_queryset(self): 25 | return self.repository.credentials.all() 26 | 27 | def get_context_data(self, **kwargs): 28 | context = super().get_context_data(**kwargs) 29 | context['current_url'] = Site.objects.get_current() 30 | return context 31 | 32 | 33 | class CredentialCreateView(RepositoryMixin, generic.CreateView): 34 | form_class = forms.CredentialModelForm 35 | template_name = 'dashboard/repository_settings/credential_form.html' 36 | 37 | def get_queryset(self): 38 | return self.repository.credentials.all() 39 | 40 | def get_success_url(self): 41 | return reverse('dashboard:credential_index', kwargs={ 42 | 'repo': self.repository.slug, 43 | }) 44 | 45 | 46 | class CredentialSecretKeyView(RepositoryMixin, generic.View): 47 | 48 | def get(self, request, repo, access_key): 49 | if not request.is_ajax(): 50 | raise SuspiciousOperation 51 | credential = get_object_or_404( 52 | self.repository.credentials, access_key=access_key) 53 | return HttpResponse(credential.secret_key) 54 | 55 | 56 | class CredentialUpdateView(RepositoryMixin, generic.UpdateView): 57 | form_class = forms.CredentialModelForm 58 | slug_field = 'access_key' 59 | slug_url_kwarg = 'access_key' 60 | template_name = 'dashboard/repository_settings/credential_form.html' 61 | 62 | def get_queryset(self): 63 | return self.repository.credentials.all() 64 | 65 | def get_success_url(self): 66 | return reverse('dashboard:credential_index', kwargs={ 67 | 'repo': self.repository.slug, 68 | }) 69 | 70 | 71 | class CredentialDeleteView(RepositoryMixin, generic.DeleteView): 72 | slug_field = 'access_key' 73 | slug_url_kwarg = 'access_key' 74 | 75 | def get_success_url(self): 76 | return reverse('dashboard:credential_index', kwargs={ 77 | 'repo': self.repository.slug, 78 | }) 79 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/views/misc.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.urls import reverse 5 | from django.views import generic 6 | 7 | from localshop.apps.dashboard import forms 8 | from localshop.apps.dashboard.views.repository import RepositoryMixin 9 | from localshop.apps.packages import models 10 | 11 | 12 | class IndexView(LoginRequiredMixin, generic.TemplateView): 13 | template_name = 'dashboard/index.html' 14 | 15 | def get_context_data(self): 16 | return { 17 | 'repositories': self.repositories, 18 | } 19 | 20 | @property 21 | def repositories(self): 22 | user = self.request.user 23 | 24 | if user.is_superuser: 25 | return models.Repository.objects.all() 26 | 27 | repositories = set() 28 | for team_membership in user.team_memberships.all(): 29 | for repository in team_membership.team.repositories.all(): 30 | repositories.add(repository) 31 | return sorted(repositories, key=operator.attrgetter('name')) 32 | 33 | 34 | class TeamAccessView(RepositoryMixin, generic.FormView): 35 | form_class = forms.RepositoryTeamForm 36 | template_name = 'dashboard/repository_settings/teams.html' 37 | 38 | def get_success_url(self): 39 | return reverse('dashboard:team_access', kwargs={ 40 | 'repo': self.repository.slug, 41 | }) 42 | 43 | def form_valid(self, form): 44 | form.save() 45 | return super().form_valid(form) 46 | -------------------------------------------------------------------------------- /src/localshop/apps/dashboard/views/package.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.urls import reverse 3 | from django.shortcuts import redirect, get_object_or_404 4 | from django.utils.text import gettext_lazy as _ 5 | from django.views import generic 6 | 7 | from localshop.apps.dashboard import forms 8 | from localshop.apps.dashboard.views.repository import RepositoryMixin 9 | from localshop.apps.packages import models 10 | from localshop.apps.packages.tasks import download_file, fetch_package 11 | from localshop.utils import enqueue 12 | 13 | __all__ = [ 14 | 'PackageAddView', 15 | 'PackageMirrorFileView', 16 | 'PackageRefreshView', 17 | 'PackageDetailView' 18 | ] 19 | 20 | 21 | class PackageAddView(RepositoryMixin, generic.FormView): 22 | form_class = forms.PackageAddForm 23 | require_role = ['developer', 'owner'] 24 | 25 | def form_valid(self, form): 26 | package_name = form.cleaned_data['name'] 27 | messages.info( 28 | self.request, 29 | _("Retrieving package information from '%s'" % form.cleaned_data['name'])) 30 | fetch_package.delay(self.repository.pk, package_name) 31 | 32 | return redirect(self.get_success_url()) 33 | 34 | def form_invalid(self, form): 35 | messages.error(self.request, _("Invalid package name")) 36 | return redirect(self.get_success_url()) 37 | 38 | def get_success_url(self): 39 | return reverse( 40 | 'dashboard:repository_detail', kwargs={ 41 | 'slug': self.repository.slug 42 | }) 43 | 44 | 45 | class PackageMirrorFileView(RepositoryMixin, generic.View): 46 | require_role = ['developer', 'owner'] 47 | 48 | def post(self, request, repo): 49 | pk = request.POST.get('pk') 50 | release_file = models.ReleaseFile.objects.get(pk=pk) 51 | assert release_file.release.package.repository == self.repository 52 | 53 | messages.info( 54 | request, _("Mirroring %s in the background") % release_file.filename) 55 | 56 | download_file.delay(pk) 57 | return redirect( 58 | 'dashboard:package_detail', 59 | repo=self.repository.slug, 60 | name=release_file.release.package.name) 61 | 62 | 63 | class PackageRefreshView(RepositoryMixin, generic.View): 64 | def get(self, request, repo, name): 65 | package = get_object_or_404(self.repository.packages, name__iexact=name) 66 | enqueue(fetch_package, self.repository.pk, name) 67 | return redirect(package) 68 | 69 | 70 | class PackageDetailView(RepositoryMixin, generic.DetailView): 71 | require_role = ['developer', 'owner'] 72 | context_object_name = 'package' 73 | slug_url_kwarg = 'name' 74 | slug_field = 'name' 75 | template_name = 'dashboard/package_detail.html' 76 | 77 | def get_queryset(self): 78 | return self.repository.packages.all() 79 | 80 | def get_context_data(self, **kwargs): 81 | context = super().get_context_data(**kwargs) 82 | context['release'] = self.object.last_release 83 | return context 84 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/packages/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/packages/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from localshop.apps.packages import models 4 | 5 | 6 | @admin.register(models.Classifier) 7 | class ClassifierAdmin(admin.ModelAdmin): 8 | list_display = ['name'] 9 | 10 | 11 | @admin.register(models.Repository) 12 | class RepositoryAdmin(admin.ModelAdmin): 13 | list_display = ['name', 'slug'] 14 | 15 | 16 | class ReleaseFileInline(admin.TabularInline): 17 | model = models.ReleaseFile 18 | 19 | 20 | @admin.register(models.Package) 21 | class PackageAdmin(admin.ModelAdmin): 22 | list_display = ['repository', '__str__', 'created', 'modified', 'is_local'] 23 | list_filter = ['is_local', 'repository'] 24 | search_fields = ['name'] 25 | 26 | 27 | @admin.register(models.Release) 28 | class ReleaseAdmin(admin.ModelAdmin): 29 | inlines = [ReleaseFileInline] 30 | list_display = ['__str__', 'package', 'created', 'modified'] 31 | list_filter = ['package__repository', 'package'] 32 | search_fields = ['version', 'package__name'] 33 | ordering = ['-created', 'version'] 34 | 35 | 36 | @admin.register(models.ReleaseFile) 37 | class ReleaseFileAdmin(admin.ModelAdmin): 38 | list_filter = ['user', 'release__package__repository'] 39 | list_display = ['__str__', 'created', 'modified', 'md5_digest', 'url'] 40 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from localshop.apps.packages import models 4 | 5 | 6 | class PypiReleaseDataForm(forms.ModelForm): 7 | class Meta: 8 | model = models.Release 9 | fields = [ 10 | 'author', 'author_email', 'description', 'download_url', 11 | 'home_page', 'license', 'summary', 'version', 12 | ] 13 | 14 | 15 | class PackageForm(forms.ModelForm): 16 | class Meta: 17 | model = models.Package 18 | fields = [ 19 | 'name', 20 | ] 21 | 22 | def __init__(self, *args, **kwargs): 23 | self._repository = kwargs.pop('repository') 24 | super().__init__(*args, **kwargs) 25 | self.base_fields['name'].error_messages.update({ 26 | 'invalid': 'Enter a valid name consisting of letters, numbers, underscores or hyphens' 27 | }) 28 | 29 | def save(self, commit=False): 30 | obj = super().save(commit=commit) 31 | obj.is_local = True 32 | obj.repository = self._repository 33 | obj.save() 34 | return obj 35 | 36 | 37 | class ReleaseForm(forms.ModelForm): 38 | """Used to process upload or register actions from the pypi endpoint""" 39 | 40 | class Meta: 41 | model = models.Release 42 | fields = [ 43 | 'author', 'author_email', 'description', 'download_url', 44 | 'home_page', 'license', 'metadata_version', 'summary', 'version', 45 | ] 46 | 47 | def clean_download_url(self): 48 | value = self.cleaned_data.get('value') 49 | if value is None: 50 | return '' 51 | return value 52 | 53 | def clean(self): 54 | # Distutils sends UNKNOWN for empty fields (e.g platform) 55 | result = { 56 | key: value if value != 'UNKNOWN' else '' 57 | for key, value in self.cleaned_data.items() 58 | } 59 | return result 60 | 61 | 62 | class ReleaseFileForm(forms.ModelForm): 63 | class Meta: 64 | model = models.ReleaseFile 65 | fields = [ 66 | 'filetype', 'distribution', 'md5_digest', 'python_version', 67 | 'url' 68 | ] 69 | 70 | def __init__(self, *args, **kwargs): 71 | super().__init__(*args, **kwargs) 72 | self.fields['pyversion'] = self.fields.pop('python_version') 73 | self.fields['pyversion'].required = False 74 | 75 | def save(self, commit=True): 76 | obj = super().save(False) 77 | obj.python_version = self.cleaned_data['pyversion'] or 'source' 78 | if commit: 79 | obj.save() 80 | return obj 81 | 82 | def clean(self): 83 | # Distutils sends UNKNOWN for empty fields (e.g platform) 84 | result = { 85 | key: value if value != 'UNKNOWN' else '' 86 | for key, value in self.cleaned_data.items() 87 | } 88 | return result 89 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0002_repository.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.utils.timezone 5 | import model_utils.fields 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('packages', '0001_initial'), 13 | ('accounts', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Repository', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 22 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 23 | ('name', models.CharField(max_length=250)), 24 | ('slug', models.CharField(max_length=200, unique=True)), 25 | ('description', models.CharField(default='', max_length=500, blank=True)), 26 | ('teams', models.ManyToManyField(related_name='repositories', to='accounts.Team', blank=True)), 27 | ], 28 | options={ 29 | 'abstract': False, 30 | }, 31 | bases=(models.Model,), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0003_default_repo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | def forwards(apps, schema_editor): 8 | Repository = apps.get_model('packages', 'Repository') 9 | Package = apps.get_model('packages', 'Package') 10 | if Package.objects.count() > 0: 11 | repo = Repository.objects.create(name='Default', slug='default') 12 | 13 | Team = apps.get_model('accounts', 'Team') 14 | team = Team.objects.filter(name='Default').first() 15 | if team: 16 | repo.teams.add(team) 17 | 18 | 19 | 20 | def backwards(apps, schema_editor): 21 | Repository = apps.get_model('packages', 'Repository') 22 | Repository.objects.all().delete() 23 | 24 | 25 | class Migration(migrations.Migration): 26 | 27 | dependencies = [ 28 | ('packages', '0002_repository'), 29 | ] 30 | 31 | operations = [ 32 | migrations.RunPython( 33 | code=forwards, 34 | reverse_code=backwards), 35 | ] 36 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0004_auto_20150517_1612.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('packages', '0003_default_repo'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='package', 16 | name='repository', 17 | field=models.ForeignKey(related_name='packages', default=1, to='packages.Repository', on_delete=models.CASCADE), 18 | preserve_default=False, 19 | ), 20 | migrations.AlterField( 21 | model_name='package', 22 | name='name', 23 | field=models.SlugField(max_length=200), 24 | preserve_default=True, 25 | ), 26 | migrations.AlterUniqueTogether( 27 | name='package', 28 | unique_together=set([('repository', 'name')]), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0005_auto_20150525_1931.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | import localshop.apps.packages.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('packages', '0004_auto_20150517_1612'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='repository', 18 | name='enable_auto_mirroring', 19 | field=models.BooleanField(default=True), 20 | preserve_default=True, 21 | ), 22 | migrations.AlterField( 23 | model_name='releasefile', 24 | name='distribution', 25 | field=models.FileField(max_length=512, upload_to=localshop.apps.packages.models.release_file_upload_to), 26 | preserve_default=True, 27 | ), 28 | migrations.AlterField( 29 | model_name='repository', 30 | name='description', 31 | field=models.CharField(max_length=500, blank=True), 32 | preserve_default=True, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0006_repository_upstream_pypi_url.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('packages', '0005_auto_20150525_1931'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='repository', 16 | name='upstream_pypi_url', 17 | field=models.CharField(default=b'https://pypi.python.org/simple', help_text="The upstream pypi URL (default 'https://pypi.python.org/simple'", max_length=500), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0007_auto_20150909_2245.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('packages', '0006_repository_upstream_pypi_url'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='releasefile', 16 | name='python_version', 17 | field=models.CharField(max_length=50), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0008_auto_20171116_2112.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-11-16 21:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('packages', '0007_auto_20150909_2245'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='releasefile', 17 | name='filetype', 18 | field=models.CharField(choices=[('sdist', 'Source'), ('bdist_egg', 'Egg'), ('bdist_msi', 'MSI'), ('bdist_dmg', 'DMG'), ('bdist_rpm', 'RPM'), ('bdist_dumb', 'bdist_dumb'), ('bdist_wininst', 'bdist_wininst'), ('bdist_wheel', 'bdist_wheel')], max_length=25), 19 | ), 20 | migrations.AlterField( 21 | model_name='repository', 22 | name='upstream_pypi_url', 23 | field=models.CharField(default='https://pypi.python.org/simple', help_text='The upstream pypi URL (default: https://pypi.python.org/simple)', max_length=500), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0009_package_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-11-25 14:16 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | import re 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('packages', '0008_auto_20171116_2112'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='package', 19 | name='name', 20 | field=models.CharField(db_index=True, max_length=200, validators=[django.core.validators.RegexValidator(re.compile('^[-a-zA-Z0-9_\\.]+\\Z', 32), 'Enter a valid package name consisting', 'invalid')]), 21 | ), 22 | migrations.AlterField( 23 | model_name='repository', 24 | name='upstream_pypi_url', 25 | field=models.CharField(blank=True, default='https://pypi.python.org/simple', help_text='The upstream pypi URL (default: https://pypi.python.org/simple)', max_length=500), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/0010_auto_20200612_1204.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-06-12 12:04 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import re 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('packages', '0009_package_name'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='package', 17 | options={'ordering': ['name']}, 18 | ), 19 | migrations.AlterField( 20 | model_name='package', 21 | name='name', 22 | field=models.CharField(db_index=True, max_length=200, validators=[django.core.validators.RegexValidator(re.compile('^[-a-zA-Z0-9_.]+\\Z'), 'Enter a valid package name consisting', 'invalid')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/packages/migrations/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/packages/mixins.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | 3 | from localshop.apps.packages.models import Repository 4 | 5 | 6 | class RepositoryMixin(object): 7 | def dispatch(self, request, *args, **kwargs): 8 | self.repository = get_object_or_404( 9 | Repository.objects, slug=kwargs['repo']) 10 | return super().dispatch(request, *args, **kwargs) 11 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/pypi.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | 5 | 6 | def get_search_names(name): 7 | """Return a list of values to search on when we are looking for a package 8 | with the given name. 9 | 10 | This is required to search on both pyramid_debugtoolbar and 11 | pyramid-debugtoolbar. 12 | 13 | """ 14 | parts = re.split('[-_.]', name) 15 | if len(parts) == 1: 16 | return parts 17 | 18 | result = set() 19 | for i in range(len(parts) - 1, 0, -1): 20 | for s1 in '-_.': 21 | prefix = s1.join(parts[:i]) 22 | for s2 in '-_.': 23 | suffix = s2.join(parts[i:]) 24 | for s3 in '-_.': 25 | result.add(s3.join([prefix, suffix])) 26 | return list(result) 27 | 28 | 29 | def get_package_information(index_url, package_name): 30 | index_url = index_url.rstrip('/') 31 | response = requests.get('%s/%s/json' % (index_url, package_name)) 32 | if response.status_code == 200: 33 | return response.json() 34 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.decorators.cache import cache_page 3 | 4 | from localshop.apps.packages import views 5 | 6 | app_name = 'packages' 7 | 8 | urlpatterns = [ 9 | url(r'^(?P[-._\w]+)/?$', views.SimpleIndex.as_view(), 10 | name='simple_index'), 11 | 12 | url(r'^(?P[-._\w]+)/(?P[-._\w]+)/$', 13 | cache_page(60)(views.SimpleDetail.as_view()), 14 | name='simple_detail'), 15 | 16 | url(r'^(?P[-._\w]+)/download/(?P[-._\w]+)/(?P\d+)/(?P.*)$', 17 | views.DownloadReleaseFile.as_view(), name='download'), 18 | ] 19 | -------------------------------------------------------------------------------- /src/localshop/apps/packages/xmlrpc.py: -------------------------------------------------------------------------------- 1 | 2 | from django.db.models import Q 3 | from django.http import HttpResponse 4 | from django.utils import six 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | from localshop.apps.packages import models 8 | from localshop.apps.permissions.utils import credentials_required 9 | 10 | if six.PY2: 11 | from SimpleXMLRPCServer import SimpleXMLRPCDispatcher 12 | else: 13 | from xmlrpc.server import SimpleXMLRPCDispatcher 14 | 15 | dispatcher = SimpleXMLRPCDispatcher(allow_none=False, encoding=None) 16 | 17 | 18 | @csrf_exempt 19 | @credentials_required 20 | def handle_request(request): 21 | response = HttpResponse(content_type='application/xml') 22 | response.write(dispatcher._marshaled_dispatch(request.body)) 23 | return response 24 | 25 | 26 | def search(spec, operator='and'): 27 | """Implement xmlrpc search command. 28 | 29 | This only searches through the mirrored and private packages 30 | 31 | """ 32 | field_map = { 33 | 'name': 'name__icontains', 34 | 'summary': 'releases__summary__icontains', 35 | } 36 | 37 | query_filter = None 38 | for field, values in spec.items(): 39 | for value in values: 40 | if field not in field_map: 41 | continue 42 | 43 | field_filter = Q(**{field_map[field]: value}) 44 | if not query_filter: 45 | query_filter = field_filter 46 | continue 47 | 48 | if operator == 'and': 49 | query_filter &= field_filter 50 | else: 51 | query_filter |= field_filter 52 | 53 | result = [] 54 | packages = models.Package.objects.filter(query_filter).all()[:20] 55 | for package in packages: 56 | release = package.releases.all()[0] 57 | result.append({ 58 | 'name': package.name, 59 | 'summary': release.summary, 60 | 'version': release.version, 61 | '_pypi_ordering': 0, 62 | }) 63 | return result 64 | 65 | 66 | dispatcher.register_function(search, 'search') 67 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/permissions/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/permissions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from localshop.apps.permissions import models 4 | 5 | 6 | @admin.register(models.CIDR) 7 | class CidrAdmin(admin.ModelAdmin): 8 | list_display = ['cidr', 'label'] 9 | 10 | 11 | @admin.register(models.Credential) 12 | class CredentialAdmin(admin.ModelAdmin): 13 | list_display = [ 14 | 'repository', 'access_key', 'created', 'comment', 'allow_upload'] 15 | list_filter = ['repository', 'allow_upload'] 16 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/0001_squashed_0002_remove_userena.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | replaces = [ 12 | ('permissions', '0001_initial'), 13 | ('permissions', '0002_remove_userena') 14 | ] 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='CIDR', 23 | fields=[ 24 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 25 | ('cidr', models.CharField(help_text=b'IP addresses and/or subnet', unique=True, max_length=128, verbose_name=b'CIDR')), 26 | ('label', models.CharField(help_text=b'Human-readable name (optional)', max_length=128, null=True, verbose_name=b'label', blank=True)), 27 | ('require_credentials', models.BooleanField(default=True)), 28 | ], 29 | options={ 30 | 'permissions': (('view_cidr', 'Can view CIDR'),), 31 | }, 32 | bases=(models.Model,), 33 | ), 34 | migrations.CreateModel( 35 | name='Credential', 36 | fields=[ 37 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 38 | ('access_key', models.UUIDField(editable=False, max_length=32, blank=True, help_text=b'The access key', unique=True, verbose_name=b'Access key', db_index=True)), 39 | ('secret_key', models.UUIDField(editable=False, max_length=32, blank=True, help_text=b'The secret key', unique=True, verbose_name=b'Secret key', db_index=True)), 40 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 41 | ('deactivated', models.DateTimeField(null=True, blank=True)), 42 | ('comment', models.CharField(default=b'', max_length=255, null=True, help_text=b"A comment about this credential, e.g. where it's being used", blank=True)), 43 | ('creator', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 44 | ], 45 | options={ 46 | 'ordering': ['-created'], 47 | 'permissions': (('view_credential', 'Can view credential'),), 48 | }, 49 | bases=(models.Model,), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/0002_auto_20150517_1857.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.utils.timezone 5 | import model_utils.fields 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('packages', '0003_default_repo'), 13 | ('permissions', '0002_remove_userena'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name='credential', 19 | name='creator', 20 | ), 21 | migrations.AddField( 22 | model_name='cidr', 23 | name='repository', 24 | field=models.ForeignKey(related_name='cidr_list', default=1, to='packages.Repository', on_delete=models.CASCADE), 25 | preserve_default=False, 26 | ), 27 | migrations.AddField( 28 | model_name='credential', 29 | name='allow_upload', 30 | field=models.BooleanField(default=True, help_text='Indicate if these credentials allow uploading new files'), 31 | ), 32 | migrations.AddField( 33 | model_name='credential', 34 | name='repository', 35 | field=models.ForeignKey(related_name='credentials', default=1, to='packages.Repository', on_delete=models.CASCADE), 36 | preserve_default=False, 37 | ), 38 | migrations.AlterField( 39 | model_name='cidr', 40 | name='cidr', 41 | field=models.CharField(help_text=b'IP addresses and/or subnet', max_length=128, verbose_name=b'CIDR'), 42 | ), 43 | migrations.AlterField( 44 | model_name='credential', 45 | name='created', 46 | field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False), 47 | ), 48 | migrations.AlterUniqueTogether( 49 | name='cidr', 50 | unique_together=set([('repository', 'cidr')]), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/0002_remove_userena.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('permissions', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.DeleteModel( 15 | name='AuthProfile', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/0003_auto_20171116_2112.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-11-16 21:12 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('permissions', '0002_auto_20150517_1857'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='cidr', 19 | name='cidr', 20 | field=models.CharField(help_text='IP addresses and/or subnet', max_length=128, verbose_name='CIDR'), 21 | ), 22 | migrations.AlterField( 23 | model_name='cidr', 24 | name='label', 25 | field=models.CharField(blank=True, help_text='Human-readable name (optional)', max_length=128, null=True, verbose_name='label'), 26 | ), 27 | migrations.AlterField( 28 | model_name='credential', 29 | name='access_key', 30 | field=models.UUIDField(db_index=True, default=uuid.uuid4, help_text='The access key', verbose_name='Access key'), 31 | ), 32 | migrations.AlterField( 33 | model_name='credential', 34 | name='comment', 35 | field=models.CharField(blank=True, default='', help_text="A comment about this credential, e.g. where it's being used", max_length=255, null=True), 36 | ), 37 | migrations.AlterField( 38 | model_name='credential', 39 | name='secret_key', 40 | field=models.UUIDField(db_index=True, default=uuid.uuid4, help_text='The secret key', verbose_name='Secret key'), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/0004_auto_20200612_1204.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-06-12 12:04 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('permissions', '0003_auto_20171116_2112'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='cidr', 15 | options={}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='credential', 19 | options={'ordering': ['-created']}, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/apps/permissions/migrations/__init__.py -------------------------------------------------------------------------------- /src/localshop/apps/permissions/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import netaddr 4 | from django.db import models 5 | from django.utils.translation import ugettext as _ 6 | from model_utils.fields import AutoCreatedField 7 | 8 | 9 | class CIDRManager(models.Manager): 10 | def has_access(self, ip_addr, with_credentials=True): 11 | cidrs = self.filter( 12 | require_credentials=with_credentials 13 | ).values_list('cidr', flat=True) 14 | return bool(netaddr.all_matching_cidrs(ip_addr, cidrs)) 15 | 16 | 17 | class CIDR(models.Model): 18 | """Allow access based on the IP address of the client.""" 19 | repository = models.ForeignKey( 20 | 'packages.Repository', related_name='cidr_list', on_delete=models.CASCADE) 21 | cidr = models.CharField( 22 | 'CIDR', max_length=128, help_text='IP addresses and/or subnet') 23 | label = models.CharField( 24 | 'label', max_length=128, blank=True, null=True, 25 | help_text='Human-readable name (optional)') 26 | require_credentials = models.BooleanField(default=True) 27 | 28 | objects = CIDRManager() 29 | 30 | def __str__(self): 31 | return self.cidr 32 | 33 | class Meta: 34 | unique_together = [ 35 | ('repository', 'cidr'), 36 | ] 37 | 38 | 39 | class CredentialQuerySet(models.QuerySet): 40 | 41 | def active(self): 42 | return self.filter(deactivated__isnull=True) 43 | 44 | def authenticate(self, access_key, secret_key): 45 | return( 46 | self.active() 47 | .filter(access_key=access_key, secret_key=secret_key) 48 | .first()) 49 | 50 | 51 | class Credential(models.Model): 52 | """Credentials are repository bound""" 53 | created = AutoCreatedField() 54 | 55 | repository = models.ForeignKey('packages.Repository', related_name='credentials', on_delete=models.CASCADE) 56 | access_key = models.UUIDField( 57 | verbose_name='Access key', 58 | help_text='The access key', 59 | default=uuid.uuid4, db_index=True) 60 | secret_key = models.UUIDField( 61 | verbose_name='Secret key', 62 | help_text='The secret key', default=uuid.uuid4, db_index=True) 63 | comment = models.CharField( 64 | max_length=255, blank=True, null=True, default='', 65 | help_text="A comment about this credential, e.g. where it's being used") 66 | 67 | allow_upload = models.BooleanField( 68 | default=True, 69 | help_text=_("Indicate if these credentials allow uploading new files")) 70 | deactivated = models.DateTimeField(blank=True, null=True) 71 | 72 | objects = CredentialQuerySet.as_manager() 73 | 74 | def __str__(self): 75 | return self.access_key.hex 76 | 77 | class Meta: 78 | ordering = ['-created'] 79 | -------------------------------------------------------------------------------- /src/localshop/apps/permissions/utils.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from functools import wraps 3 | 4 | from django.conf import settings 5 | from django.contrib.auth import authenticate, login 6 | from django.db import DatabaseError, DataError 7 | from django.http import HttpResponseForbidden 8 | from django.utils.decorators import available_attrs 9 | 10 | from localshop.apps.permissions.models import CIDR 11 | from localshop.http import HttpResponseUnauthorized 12 | 13 | 14 | def decode_credentials(auth): 15 | auth = b64decode(auth.strip()).decode('utf-8') 16 | return auth.split(':', 1) 17 | 18 | 19 | def split_auth(request): 20 | auth = request.META.get('HTTP_AUTHORIZATION') 21 | if auth: 22 | method, identity = auth.split(' ', 1) 23 | else: 24 | method, identity = None, None 25 | return method, identity 26 | 27 | 28 | def get_basic_auth_data(request): 29 | method, identity = split_auth(request) 30 | if method is not None and method.lower() == 'basic': 31 | return decode_credentials(identity) 32 | return None, None 33 | 34 | 35 | def authenticate_user(request): 36 | key, secret = get_basic_auth_data(request) 37 | if key and secret: 38 | try: 39 | user = authenticate(access_key=key, secret_key=secret) 40 | except (DatabaseError, DataError): 41 | # we fallback on django user auth in case of DB error 42 | user = None 43 | if not user: 44 | user = authenticate(username=key, password=secret) 45 | return user 46 | 47 | 48 | def credentials_required(view_func): 49 | """ 50 | This decorator should be used with views that need simple authentication 51 | against Django's authentication framework. 52 | """ 53 | @wraps(view_func, assigned=available_attrs(view_func)) 54 | def decorator(request, *args, **kwargs): 55 | if settings.LOCALSHOP_USE_PROXIED_IP: 56 | try: 57 | ip_addr = request.META['HTTP_X_FORWARDED_FOR'] 58 | except KeyError: 59 | return HttpResponseForbidden('No permission') 60 | else: 61 | # HTTP_X_FORWARDED_FOR can be a comma-separated list of IPs. 62 | # The client's IP will be the first one. 63 | ip_addr = ip_addr.split(",")[0].strip() 64 | else: 65 | ip_addr = request.META['REMOTE_ADDR'] 66 | 67 | if CIDR.objects.has_access(ip_addr, with_credentials=False): 68 | return view_func(request, *args, **kwargs) 69 | 70 | if not CIDR.objects.has_access(ip_addr, with_credentials=True): 71 | return HttpResponseForbidden('No permission') 72 | 73 | # Just return the original view because already logged in 74 | if request.user.is_authenticated: 75 | return view_func(request, *args, **kwargs) 76 | 77 | user = authenticate_user(request) 78 | if user is not None: 79 | login(request, user) 80 | return view_func(request, *args, **kwargs) 81 | 82 | return HttpResponseUnauthorized(content='Authorization Required') 83 | return decorator 84 | -------------------------------------------------------------------------------- /src/localshop/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import celery # noqa isort:skip 4 | from django.conf import settings # noqa isort:skip 5 | 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'localshop.settings') 7 | 8 | app = celery.Celery('localshop') 9 | app.config_from_object('django.conf:settings', namespace='CELERY') 10 | -------------------------------------------------------------------------------- /src/localshop/http.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpResponse 3 | 4 | BASIC_AUTH_REALM = getattr(settings, 'BASIC_AUTH_REALM', 'pypi') 5 | 6 | 7 | class HttpResponseUnauthorized(HttpResponse): 8 | status_code = 401 9 | 10 | def __init__(self, basic_auth_realm=None, *args, **kwargs): 11 | super(HttpResponseUnauthorized, self).__init__(*args, **kwargs) 12 | if basic_auth_realm is None: 13 | basic_auth_realm = BASIC_AUTH_REALM 14 | self['WWW-Authenticate'] = 'Basic realm="%s"' % basic_auth_realm 15 | -------------------------------------------------------------------------------- /src/localshop/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/management/__init__.py -------------------------------------------------------------------------------- /src/localshop/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/management/commands/__init__.py -------------------------------------------------------------------------------- /src/localshop/management/commands/create_default_user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from localshop.apps.accounts.models import User 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | def handle(self, *args, **kwargs): 11 | num_superusers = User.objects.filter(is_superuser=True).count() 12 | if not num_superusers: 13 | password = str(uuid.uuid4()) 14 | 15 | user = User.objects.create( 16 | username='admin', 17 | is_superuser=True, 18 | is_staff=True) 19 | user.set_password(password) 20 | user.save() 21 | 22 | self.stdout.write("You can now login with the credentials: ") 23 | self.stdout.write(" Username: %s" % user.username) 24 | self.stdout.write(" Password: %s" % password) 25 | -------------------------------------------------------------------------------- /src/localshop/management/commands/init.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.core.management.base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | 7 | def handle(self, *args, **kwargs): 8 | call_command('migrate', database='default', interactive=False) 9 | call_command('create_default_user', interactive=False) 10 | -------------------------------------------------------------------------------- /src/localshop/management/commands/repository_refresh.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from localshop.apps.packages import tasks 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | def handle(self, *args, **kwargs): 9 | tasks.refresh_repository_mirrors() 10 | -------------------------------------------------------------------------------- /src/localshop/runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | def main(): 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'localshop.settings') 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | 13 | 14 | if __name__ == "__main__": 15 | main() 16 | -------------------------------------------------------------------------------- /src/localshop/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .defaults import * # noqa 2 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/static/localshop/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /src/localshop/static/localshop/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/static/localshop/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /src/localshop/static/localshop/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/static/localshop/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /src/localshop/static/localshop/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/static/localshop/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/alerts.less: -------------------------------------------------------------------------------- 1 | // 2 | // Alerts 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base styles 7 | // ------------------------- 8 | 9 | .alert { 10 | padding: @alert-padding; 11 | margin-bottom: @line-height-computed; 12 | border: 1px solid transparent; 13 | border-radius: @alert-border-radius; 14 | 15 | // Headings for larger alerts 16 | h4 { 17 | margin-top: 0; 18 | // Specified for the h4 to prevent conflicts of changing @headings-color 19 | color: inherit; 20 | } 21 | 22 | // Provide class for links that match alerts 23 | .alert-link { 24 | font-weight: @alert-link-font-weight; 25 | } 26 | 27 | // Improve alignment and spacing of inner content 28 | > p, 29 | > ul { 30 | margin-bottom: 0; 31 | } 32 | 33 | > p + p { 34 | margin-top: 5px; 35 | } 36 | } 37 | 38 | // Dismissible alerts 39 | // 40 | // Expand the right padding and account for the close button's positioning. 41 | 42 | .alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0. 43 | .alert-dismissible { 44 | padding-right: (@alert-padding + 20); 45 | 46 | // Adjust close link position 47 | .close { 48 | position: relative; 49 | top: -2px; 50 | right: -21px; 51 | color: inherit; 52 | } 53 | } 54 | 55 | // Alternate styles 56 | // 57 | // Generate contextual modifier classes for colorizing the alert. 58 | 59 | .alert-success { 60 | .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text); 61 | } 62 | 63 | .alert-info { 64 | .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text); 65 | } 66 | 67 | .alert-warning { 68 | .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text); 69 | } 70 | 71 | .alert-danger { 72 | .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text); 73 | } 74 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/badges.less: -------------------------------------------------------------------------------- 1 | // 2 | // Badges 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base class 7 | .badge { 8 | display: inline-block; 9 | min-width: 10px; 10 | padding: 3px 7px; 11 | font-size: @font-size-small; 12 | font-weight: @badge-font-weight; 13 | color: @badge-color; 14 | line-height: @badge-line-height; 15 | vertical-align: baseline; 16 | white-space: nowrap; 17 | text-align: center; 18 | background-color: @badge-bg; 19 | border-radius: @badge-border-radius; 20 | 21 | // Empty badges collapse automatically (not available in IE8) 22 | &:empty { 23 | display: none; 24 | } 25 | 26 | // Quick fix for badges in buttons 27 | .btn & { 28 | position: relative; 29 | top: -1px; 30 | } 31 | 32 | .btn-xs &, 33 | .btn-group-xs > .btn & { 34 | top: 0; 35 | padding: 1px 5px; 36 | } 37 | 38 | // Hover state, but only for links 39 | a& { 40 | &:hover, 41 | &:focus { 42 | color: @badge-link-hover-color; 43 | text-decoration: none; 44 | cursor: pointer; 45 | } 46 | } 47 | 48 | // Account for badges in navs 49 | .list-group-item.active > &, 50 | .nav-pills > .active > a > & { 51 | color: @badge-active-color; 52 | background-color: @badge-active-bg; 53 | } 54 | 55 | .list-group-item > & { 56 | float: right; 57 | } 58 | 59 | .list-group-item > & + & { 60 | margin-right: 5px; 61 | } 62 | 63 | .nav-pills > li > a > & { 64 | margin-left: 3px; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/bootstrap.less: -------------------------------------------------------------------------------- 1 | // Core variables and mixins 2 | @import "variables.less"; 3 | @import "mixins.less"; 4 | 5 | // Reset and dependencies 6 | @import "normalize.less"; 7 | @import "print.less"; 8 | @import "glyphicons.less"; 9 | 10 | // Core CSS 11 | @import "scaffolding.less"; 12 | @import "type.less"; 13 | @import "code.less"; 14 | @import "grid.less"; 15 | @import "tables.less"; 16 | @import "forms.less"; 17 | @import "buttons.less"; 18 | 19 | // Components 20 | @import "component-animations.less"; 21 | @import "dropdowns.less"; 22 | @import "button-groups.less"; 23 | @import "input-groups.less"; 24 | @import "navs.less"; 25 | @import "navbar.less"; 26 | @import "breadcrumbs.less"; 27 | @import "pagination.less"; 28 | @import "pager.less"; 29 | @import "labels.less"; 30 | @import "badges.less"; 31 | @import "jumbotron.less"; 32 | @import "thumbnails.less"; 33 | @import "alerts.less"; 34 | @import "progress-bars.less"; 35 | @import "media.less"; 36 | @import "list-group.less"; 37 | @import "panels.less"; 38 | @import "responsive-embed.less"; 39 | @import "wells.less"; 40 | @import "close.less"; 41 | 42 | // Components w/ JavaScript 43 | @import "modals.less"; 44 | @import "tooltip.less"; 45 | @import "popovers.less"; 46 | @import "carousel.less"; 47 | 48 | // Utility classes 49 | @import "utilities.less"; 50 | @import "responsive-utilities.less"; 51 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/breadcrumbs.less: -------------------------------------------------------------------------------- 1 | // 2 | // Breadcrumbs 3 | // -------------------------------------------------- 4 | 5 | 6 | .breadcrumb { 7 | padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal; 8 | margin-bottom: @line-height-computed; 9 | list-style: none; 10 | background-color: @breadcrumb-bg; 11 | border-radius: @border-radius-base; 12 | 13 | > li { 14 | display: inline-block; 15 | 16 | + li:before { 17 | content: "@{breadcrumb-separator}\00a0"; // Unicode space added since inline-block means non-collapsing white-space 18 | padding: 0 5px; 19 | color: @breadcrumb-color; 20 | } 21 | } 22 | 23 | > .active { 24 | color: @breadcrumb-active-color; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/close.less: -------------------------------------------------------------------------------- 1 | // 2 | // Close icons 3 | // -------------------------------------------------- 4 | 5 | 6 | .close { 7 | float: right; 8 | font-size: (@font-size-base * 1.5); 9 | font-weight: @close-font-weight; 10 | line-height: 1; 11 | color: @close-color; 12 | text-shadow: @close-text-shadow; 13 | .opacity(.2); 14 | 15 | &:hover, 16 | &:focus { 17 | color: @close-color; 18 | text-decoration: none; 19 | cursor: pointer; 20 | .opacity(.5); 21 | } 22 | 23 | // Additional properties for button version 24 | // iOS requires the button element instead of an anchor tag. 25 | // If you want the anchor version, it requires `href="#"`. 26 | // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile 27 | button& { 28 | padding: 0; 29 | cursor: pointer; 30 | background: transparent; 31 | border: 0; 32 | -webkit-appearance: none; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/code.less: -------------------------------------------------------------------------------- 1 | // 2 | // Code (inline and block) 3 | // -------------------------------------------------- 4 | 5 | 6 | // Inline and block code styles 7 | code, 8 | kbd, 9 | pre, 10 | samp { 11 | font-family: @font-family-monospace; 12 | } 13 | 14 | // Inline code 15 | code { 16 | padding: 2px 4px; 17 | font-size: 90%; 18 | color: @code-color; 19 | background-color: @code-bg; 20 | border-radius: @border-radius-base; 21 | } 22 | 23 | // User input typically entered via keyboard 24 | kbd { 25 | padding: 2px 4px; 26 | font-size: 90%; 27 | color: @kbd-color; 28 | background-color: @kbd-bg; 29 | border-radius: @border-radius-small; 30 | box-shadow: inset 0 -1px 0 rgba(0,0,0,.25); 31 | 32 | kbd { 33 | padding: 0; 34 | font-size: 100%; 35 | font-weight: bold; 36 | box-shadow: none; 37 | } 38 | } 39 | 40 | // Blocks of code 41 | pre { 42 | display: block; 43 | padding: ((@line-height-computed - 1) / 2); 44 | margin: 0 0 (@line-height-computed / 2); 45 | font-size: (@font-size-base - 1); // 14px to 13px 46 | line-height: @line-height-base; 47 | word-break: break-all; 48 | word-wrap: break-word; 49 | color: @pre-color; 50 | background-color: @pre-bg; 51 | border: 1px solid @pre-border-color; 52 | border-radius: @border-radius-base; 53 | 54 | // Account for some code outputs that place code tags in pre tags 55 | code { 56 | padding: 0; 57 | font-size: inherit; 58 | color: inherit; 59 | white-space: pre-wrap; 60 | background-color: transparent; 61 | border-radius: 0; 62 | } 63 | } 64 | 65 | // Enable scrollable blocks of code 66 | .pre-scrollable { 67 | max-height: @pre-scrollable-max-height; 68 | overflow-y: scroll; 69 | } 70 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/component-animations.less: -------------------------------------------------------------------------------- 1 | // 2 | // Component animations 3 | // -------------------------------------------------- 4 | 5 | // Heads up! 6 | // 7 | // We don't use the `.opacity()` mixin here since it causes a bug with text 8 | // fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552. 9 | 10 | .fade { 11 | opacity: 0; 12 | .transition(opacity .15s linear); 13 | &.in { 14 | opacity: 1; 15 | } 16 | } 17 | 18 | .collapse { 19 | display: none; 20 | 21 | &.in { display: block; } 22 | tr&.in { display: table-row; } 23 | tbody&.in { display: table-row-group; } 24 | } 25 | 26 | .collapsing { 27 | position: relative; 28 | height: 0; 29 | overflow: hidden; 30 | .transition-property(~"height, visibility"); 31 | .transition-duration(.35s); 32 | .transition-timing-function(ease); 33 | } 34 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/grid.less: -------------------------------------------------------------------------------- 1 | // 2 | // Grid system 3 | // -------------------------------------------------- 4 | 5 | 6 | // Container widths 7 | // 8 | // Set the container width, and override it for fixed navbars in media queries. 9 | 10 | .container { 11 | .container-fixed(); 12 | 13 | @media (min-width: @screen-sm-min) { 14 | width: @container-sm; 15 | } 16 | @media (min-width: @screen-md-min) { 17 | width: @container-md; 18 | } 19 | @media (min-width: @screen-lg-min) { 20 | width: @container-lg; 21 | } 22 | } 23 | 24 | 25 | // Fluid container 26 | // 27 | // Utilizes the mixin meant for fixed width containers, but without any defined 28 | // width for fluid, full width layouts. 29 | 30 | .container-fluid { 31 | .container-fixed(); 32 | } 33 | 34 | 35 | // Row 36 | // 37 | // Rows contain and clear the floats of your columns. 38 | 39 | .row { 40 | .make-row(); 41 | } 42 | 43 | 44 | // Columns 45 | // 46 | // Common styles for small and large grid columns 47 | 48 | .make-grid-columns(); 49 | 50 | 51 | // Extra small grid 52 | // 53 | // Columns, offsets, pushes, and pulls for extra small devices like 54 | // smartphones. 55 | 56 | .make-grid(xs); 57 | 58 | 59 | // Small grid 60 | // 61 | // Columns, offsets, pushes, and pulls for the small device range, from phones 62 | // to tablets. 63 | 64 | @media (min-width: @screen-sm-min) { 65 | .make-grid(sm); 66 | } 67 | 68 | 69 | // Medium grid 70 | // 71 | // Columns, offsets, pushes, and pulls for the desktop device range. 72 | 73 | @media (min-width: @screen-md-min) { 74 | .make-grid(md); 75 | } 76 | 77 | 78 | // Large grid 79 | // 80 | // Columns, offsets, pushes, and pulls for the large desktop device range. 81 | 82 | @media (min-width: @screen-lg-min) { 83 | .make-grid(lg); 84 | } 85 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/jumbotron.less: -------------------------------------------------------------------------------- 1 | // 2 | // Jumbotron 3 | // -------------------------------------------------- 4 | 5 | 6 | .jumbotron { 7 | padding: @jumbotron-padding (@jumbotron-padding / 2); 8 | margin-bottom: @jumbotron-padding; 9 | color: @jumbotron-color; 10 | background-color: @jumbotron-bg; 11 | 12 | h1, 13 | .h1 { 14 | color: @jumbotron-heading-color; 15 | } 16 | 17 | p { 18 | margin-bottom: (@jumbotron-padding / 2); 19 | font-size: @jumbotron-font-size; 20 | font-weight: 200; 21 | } 22 | 23 | > hr { 24 | border-top-color: darken(@jumbotron-bg, 10%); 25 | } 26 | 27 | .container &, 28 | .container-fluid & { 29 | border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container 30 | } 31 | 32 | .container { 33 | max-width: 100%; 34 | } 35 | 36 | @media screen and (min-width: @screen-sm-min) { 37 | padding: (@jumbotron-padding * 1.6) 0; 38 | 39 | .container &, 40 | .container-fluid & { 41 | padding-left: (@jumbotron-padding * 2); 42 | padding-right: (@jumbotron-padding * 2); 43 | } 44 | 45 | h1, 46 | .h1 { 47 | font-size: (@font-size-base * 4.5); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/labels.less: -------------------------------------------------------------------------------- 1 | // 2 | // Labels 3 | // -------------------------------------------------- 4 | 5 | .label { 6 | display: inline; 7 | padding: .2em .6em .3em; 8 | font-size: 75%; 9 | font-weight: bold; 10 | line-height: 1; 11 | color: @label-color; 12 | text-align: center; 13 | white-space: nowrap; 14 | vertical-align: baseline; 15 | border-radius: .25em; 16 | 17 | // Add hover effects, but only for links 18 | a& { 19 | &:hover, 20 | &:focus { 21 | color: @label-link-hover-color; 22 | text-decoration: none; 23 | cursor: pointer; 24 | } 25 | } 26 | 27 | // Empty labels collapse automatically (not available in IE8) 28 | &:empty { 29 | display: none; 30 | } 31 | 32 | // Quick fix for labels in buttons 33 | .btn & { 34 | position: relative; 35 | top: -1px; 36 | } 37 | } 38 | 39 | // Colors 40 | // Contextual variations (linked labels get darker on :hover) 41 | 42 | .label-default { 43 | .label-variant(@label-default-bg); 44 | } 45 | 46 | .label-primary { 47 | .label-variant(@label-primary-bg); 48 | } 49 | 50 | .label-success { 51 | .label-variant(@label-success-bg); 52 | } 53 | 54 | .label-info { 55 | .label-variant(@label-info-bg); 56 | } 57 | 58 | .label-warning { 59 | .label-variant(@label-warning-bg); 60 | } 61 | 62 | .label-danger { 63 | .label-variant(@label-danger-bg); 64 | } 65 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/media.less: -------------------------------------------------------------------------------- 1 | .media { 2 | // Proper spacing between instances of .media 3 | margin-top: 15px; 4 | 5 | &:first-child { 6 | margin-top: 0; 7 | } 8 | } 9 | 10 | .media, 11 | .media-body { 12 | zoom: 1; 13 | overflow: hidden; 14 | } 15 | 16 | .media-body { 17 | width: 10000px; 18 | } 19 | 20 | .media-object { 21 | display: block; 22 | } 23 | 24 | .media-right, 25 | .media > .pull-right { 26 | padding-left: 10px; 27 | } 28 | 29 | .media-left, 30 | .media > .pull-left { 31 | padding-right: 10px; 32 | } 33 | 34 | .media-left, 35 | .media-right, 36 | .media-body { 37 | display: table-cell; 38 | vertical-align: top; 39 | } 40 | 41 | .media-middle { 42 | vertical-align: middle; 43 | } 44 | 45 | .media-bottom { 46 | vertical-align: bottom; 47 | } 48 | 49 | // Reset margins on headings for tighter default spacing 50 | .media-heading { 51 | margin-top: 0; 52 | margin-bottom: 5px; 53 | } 54 | 55 | // Media list variation 56 | // 57 | // Undo default ul/ol styles 58 | .media-list { 59 | padding-left: 0; 60 | list-style: none; 61 | } 62 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins.less: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------------------------------- 3 | 4 | // Utilities 5 | @import "mixins/hide-text.less"; 6 | @import "mixins/opacity.less"; 7 | @import "mixins/image.less"; 8 | @import "mixins/labels.less"; 9 | @import "mixins/reset-filter.less"; 10 | @import "mixins/resize.less"; 11 | @import "mixins/responsive-visibility.less"; 12 | @import "mixins/size.less"; 13 | @import "mixins/tab-focus.less"; 14 | @import "mixins/text-emphasis.less"; 15 | @import "mixins/text-overflow.less"; 16 | @import "mixins/vendor-prefixes.less"; 17 | 18 | // Components 19 | @import "mixins/alerts.less"; 20 | @import "mixins/buttons.less"; 21 | @import "mixins/panels.less"; 22 | @import "mixins/pagination.less"; 23 | @import "mixins/list-group.less"; 24 | @import "mixins/nav-divider.less"; 25 | @import "mixins/forms.less"; 26 | @import "mixins/progress-bar.less"; 27 | @import "mixins/table-row.less"; 28 | 29 | // Skins 30 | @import "mixins/background-variant.less"; 31 | @import "mixins/border-radius.less"; 32 | @import "mixins/gradients.less"; 33 | 34 | // Layout 35 | @import "mixins/clearfix.less"; 36 | @import "mixins/center-block.less"; 37 | @import "mixins/nav-vertical-align.less"; 38 | @import "mixins/grid-framework.less"; 39 | @import "mixins/grid.less"; 40 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/alerts.less: -------------------------------------------------------------------------------- 1 | // Alerts 2 | 3 | .alert-variant(@background; @border; @text-color) { 4 | background-color: @background; 5 | border-color: @border; 6 | color: @text-color; 7 | 8 | hr { 9 | border-top-color: darken(@border, 5%); 10 | } 11 | .alert-link { 12 | color: darken(@text-color, 10%); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/background-variant.less: -------------------------------------------------------------------------------- 1 | // Contextual backgrounds 2 | 3 | .bg-variant(@color) { 4 | background-color: @color; 5 | a&:hover { 6 | background-color: darken(@color, 10%); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/border-radius.less: -------------------------------------------------------------------------------- 1 | // Single side border-radius 2 | 3 | .border-top-radius(@radius) { 4 | border-top-right-radius: @radius; 5 | border-top-left-radius: @radius; 6 | } 7 | .border-right-radius(@radius) { 8 | border-bottom-right-radius: @radius; 9 | border-top-right-radius: @radius; 10 | } 11 | .border-bottom-radius(@radius) { 12 | border-bottom-right-radius: @radius; 13 | border-bottom-left-radius: @radius; 14 | } 15 | .border-left-radius(@radius) { 16 | border-bottom-left-radius: @radius; 17 | border-top-left-radius: @radius; 18 | } 19 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/buttons.less: -------------------------------------------------------------------------------- 1 | // Button variants 2 | // 3 | // Easily pump out default styles, as well as :hover, :focus, :active, 4 | // and disabled options for all buttons 5 | 6 | .button-variant(@color; @background; @border) { 7 | color: @color; 8 | background-color: @background; 9 | border-color: @border; 10 | 11 | &:hover, 12 | &:focus, 13 | &.focus, 14 | &:active, 15 | &.active, 16 | .open > .dropdown-toggle& { 17 | color: @color; 18 | background-color: darken(@background, 10%); 19 | border-color: darken(@border, 12%); 20 | } 21 | &:active, 22 | &.active, 23 | .open > .dropdown-toggle& { 24 | background-image: none; 25 | } 26 | &.disabled, 27 | &[disabled], 28 | fieldset[disabled] & { 29 | &, 30 | &:hover, 31 | &:focus, 32 | &.focus, 33 | &:active, 34 | &.active { 35 | background-color: @background; 36 | border-color: @border; 37 | } 38 | } 39 | 40 | .badge { 41 | color: @background; 42 | background-color: @color; 43 | } 44 | } 45 | 46 | // Button sizes 47 | .button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) { 48 | padding: @padding-vertical @padding-horizontal; 49 | font-size: @font-size; 50 | line-height: @line-height; 51 | border-radius: @border-radius; 52 | } 53 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/center-block.less: -------------------------------------------------------------------------------- 1 | // Center-align a block level element 2 | 3 | .center-block() { 4 | display: block; 5 | margin-left: auto; 6 | margin-right: auto; 7 | } 8 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/clearfix.less: -------------------------------------------------------------------------------- 1 | // Clearfix 2 | // 3 | // For modern browsers 4 | // 1. The space content is one way to avoid an Opera bug when the 5 | // contenteditable attribute is included anywhere else in the document. 6 | // Otherwise it causes space to appear at the top and bottom of elements 7 | // that are clearfixed. 8 | // 2. The use of `table` rather than `block` is only necessary if using 9 | // `:before` to contain the top-margins of child elements. 10 | // 11 | // Source: http://nicolasgallagher.com/micro-clearfix-hack/ 12 | 13 | .clearfix() { 14 | &:before, 15 | &:after { 16 | content: " "; // 1 17 | display: table; // 2 18 | } 19 | &:after { 20 | clear: both; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/localshop/static/localshop/less/bootstrap/mixins/forms.less: -------------------------------------------------------------------------------- 1 | // Form validation states 2 | // 3 | // Used in forms.less to generate the form validation CSS for warnings, errors, 4 | // and successes. 5 | 6 | .form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) { 7 | // Color the label and help text 8 | .help-block, 9 | .control-label, 10 | .radio, 11 | .checkbox, 12 | .radio-inline, 13 | .checkbox-inline, 14 | &.radio label, 15 | &.checkbox label, 16 | &.radio-inline label, 17 | &.checkbox-inline label { 18 | color: @text-color; 19 | } 20 | // Set the border and box shadow on specific inputs to match 21 | .form-control { 22 | border-color: @border-color; 23 | .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work 24 | &:focus { 25 | border-color: darken(@border-color, 10%); 26 | @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%); 27 | .box-shadow(@shadow); 28 | } 29 | } 30 | // Set validation states also for addons 31 | .input-group-addon { 32 | color: @text-color; 33 | border-color: @border-color; 34 | background-color: @background-color; 35 | } 36 | // Optional feedback icon 37 | .form-control-feedback { 38 | color: @text-color; 39 | } 40 | } 41 | 42 | 43 | // Form control focus state 44 | // 45 | // Generate a customized focus state and for any input with the specified color, 46 | // which defaults to the `@input-border-focus` variable. 47 | // 48 | // We highly encourage you to not customize the default value, but instead use 49 | // this to tweak colors on an as-needed basis. This aesthetic change is based on 50 | // WebKit's default styles, but applicable to a wider range of browsers. Its 51 | // usability and accessibility should be taken into account with any change. 52 | // 53 | // Example usage: change the default blue border and shadow to white for better 54 | // contrast against a dark gray background. 55 | .form-control-focus(@color: @input-border-focus) { 56 | @color-rgba: rgba(red(@color), green(@color), blue(@color), .6); 57 | &:focus { 58 | border-color: @color; 59 | outline: 0; 60 | .box-shadow(~"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}"); 61 | } 62 | } 63 | 64 | // Form control sizing 65 | // 66 | // Relative text size, padding, and border-radii changes for form controls. For 67 | // horizontal sizing, wrap controls in the predefined grid classes. ` 43 | {% csrf_token %} 44 | 45 | 46 | 47 | 48 | {% endfor %} 49 | 50 | 51 | 59 | 60 | 61 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /src/localshop/templates/accounts/team_form.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | {% load i18n %} 3 | 4 | {% block breadcrumbs %} 5 | 15 | {% endblock %} 16 | 17 | {% block content %} 18 |
19 |
20 | {% if team %} 21 | Update team {{ team }} 22 | {% else %} 23 | Create new team 24 | {% endif %} 25 |
26 |
27 |
28 | {% include "partial/_form_fields.html" %} 29 | 30 |
31 | 32 | Cancel 33 |
34 | {% csrf_token %} 35 |
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /src/localshop/templates/accounts/team_list.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | {% load i18n %} 3 | 4 | {% block breadcrumbs %} 5 | 9 | {% endblock %} 10 | 11 | 12 | {% block content %} 13 |
14 |
15 |

Teams overview

16 | Create 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for team in team_list %} 28 | 29 | 30 | 31 | 32 | 33 | {% endfor %} 34 | 35 |
Name# MembersOwners
{{ team.name }}{{ team.members.count }}{{ team.owners|join:', ' }}
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 | Repositories 9 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for repository in repositories %} 26 | 27 | 28 | 29 | 36 | 37 | {% endfor %} 38 | 39 |
NameDescription 
{{ repository.name }}{{ repository.description }} 30 | {% if repository.enable_auto_mirroring %} 31 | 32 | Refresh 33 | 34 | {% endif %} 35 |
40 |
41 |
42 | 43 |
44 |
45 |
46 | Teams 47 | {% if user.is_superuser %} 48 | 49 | 50 | 51 | {% endif %} 52 |
53 |
    54 | {% for member in user.team_memberships.all %} 55 |
  • 56 | {% if member.is_owner %} 57 | 58 | 59 | {{ member.team }} 60 | 61 | {% else %} 62 | 63 | {{ member.team }} 64 | {% endif %} 65 |
  • 66 | {% endfor %} 67 |
68 |
69 |
70 |
71 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_create.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block content %} 6 | 7 |
8 |
9 | Create repository 10 |
11 |
12 |
13 |
14 | {% include "partial/_form_fields.html" %} 15 |
16 | 17 |
18 | 19 | Cancel 20 |
21 | {% csrf_token %} 22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load humanize %} 3 | {% load i18n %} 4 | 5 | {% block content_panel %} 6 |
7 |
8 | Repository index 9 |
10 |
11 | 12 | 13 |
14 | {% csrf_token %} 15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% for package in repository.packages.all %} 27 | 28 | 29 | 36 | 37 | 38 | {% endfor %} 39 | 40 |
NameTypeLast update
{{ package.name }} 30 | {% if package.is_local %} 31 | local package 32 | {% else %} 33 | pypi package 34 | {% endif %} 35 | {{ package.update_timestamp|naturaltime }}
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/base.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | {% load permission_tags %} 3 | {% load i18n %} 4 | 5 | 6 | {% block content %} 7 |
8 |
9 |

{% block title %}{{ view.repository.simple_index_url }} ({{ view.repository.name }}){% endblock %}

10 |

11 | {% block description %}{{ view.repository.description }}{% endblock %} 12 |

13 |
14 |
15 |
16 |
17 | 18 |
19 |
Overview
20 | 23 | {% if user|is_owner_of:view.repository %} 24 |
Settings
25 | 31 | {% endif %} 32 |
33 |
34 |
35 | {% block content_panel %} 36 | {% endblock %} 37 |
38 |
39 | {% endblock %} 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/cidr_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |

CIDR overview

6 | 7 |
8 |

Delete the CIDR {{ cidr }}?

9 | 10 |
11 | 12 | Cancel 13 |
14 | {% csrf_token %} 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/cidr_form.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |
6 |
Add CIDR
7 |
8 | 9 |
10 | {% include "partial/_form_fields.html" %} 11 | 12 |
13 | 14 | Cancel 15 |
16 | {% csrf_token %} 17 |
18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/cidr_list.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |
6 |
7 | 12 | Access control list 13 |
14 |
15 |

Overview of IP addresses which have acccess to download packages from your localshop. Hosts which are not listed here will receive a 403 error. 16 |

17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for cidr in cidr_list %} 30 | 31 | 32 | 33 | 34 | 38 | 39 | {% endfor %} 40 | 41 |
CIDRLabelCredentialsActions
{{ cidr.cidr }}{{ cidr.label|default:'n/a' }}{{ cidr.require_credentials|yesno }} 35 | edit | 36 | delete 37 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/credential_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | 3 | {% block content %} 4 |

Credential deletion

5 | 6 |
7 |

Delete the credential {{ credential }}?

8 | 9 |
10 | 11 | Cancel 12 |
13 | {% csrf_token %} 14 |
15 | {% endblock %} 16 | 17 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/credential_form.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |
6 |
Update credential comment
7 |
8 | 9 |

Here you can add or update the comment for the credential {{ credential }}.

10 |
11 | {% include "partial/_form_fields.html" %} 12 |
13 | 14 | Cancel 15 |
16 | {% csrf_token %} 17 |
18 |
19 |
20 | {% endblock %} 21 | 22 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/delete.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |

Repository delete

6 | 7 |
8 |

9 | Delete the {{ repository }} repository? 10 | This will also delete all associated packages! 11 | 12 |

13 | 14 |
15 | 16 | Cancel 17 |
18 | {% csrf_token %} 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |
6 |
Edit information
7 |
8 | 9 |
10 | {% include "partial/_form_fields.html" %} 11 | 12 |
13 | 14 |
15 | {% csrf_token %} 16 |
17 |
18 |
19 | 20 | 21 |
22 |
Delete repository
23 |
24 |
25 |

26 | Delete the {{ repository }} repository? 27 | This will also delete all associated packages! 28 |

29 | 30 |
31 | 32 |
33 | {% csrf_token %} 34 |
35 |
36 |
37 | 38 | {% endblock %} 39 | 40 | -------------------------------------------------------------------------------- /src/localshop/templates/dashboard/repository_settings/teams.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/repository_settings/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 | 6 |
7 |
Teams
8 | 9 | 10 | {% for team in view.repository.teams.all %} 11 | 12 | 15 | 23 | 24 | {% endfor %} 25 | 26 |
13 | {{ team }} 14 | 16 |
17 | 18 | 19 | 20 | {% csrf_token %} 21 |
22 |
27 | 34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /src/localshop/templates/layout.base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% load static %} 5 | {% block head %} 6 | localshop 7 | 8 | 11 | 12 | 20 | 21 | 22 | 32 | {% endblock %} 33 | 34 | 35 | 36 | 37 | {% block body %} 38 |
39 |
40 |
41 | {% block content %} 42 | {% endblock %} 43 |
44 |
45 |
46 | {% endblock %} 47 | 48 | {% block javascript %} 49 | {% endblock %} 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/localshop/templates/layout.main.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block body %} 6 | 39 | 40 | 41 | 42 |
43 | {% if messages %} 44 |
45 |
46 | {% for message in messages %} 47 |
{{ message }}
48 | {% endfor %} 49 |
50 |
51 | {% endif %} 52 |
53 |
54 | {% block content %} 55 | {% endblock %} 56 |
57 |
58 |
59 | {% endblock %} 60 | 61 | -------------------------------------------------------------------------------- /src/localshop/templates/packages/package_list.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.main.html" %} 2 | 3 | {% block content %} 4 |

Package index

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for package in packages %} 15 | 16 | 17 | 24 | 25 | {% endfor %} 26 | 27 |
NameType
{{ package.name }} 18 | {% if package.is_local %} 19 | local package 20 | {% else %} 21 | pypi package 22 | {% endif %} 23 |
28 | {% endblock %} 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/localshop/templates/packages/simple_package_detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Links for {{ package.name }} 6 | 7 | 10 | 11 | 12 | 13 |

Links for {{ package.name }}

14 | 15 | {% for release in releases %} 16 | {% for file in release.files.all %} 17 | {{ file.filename }} 18 | {% empty %} 19 | {% if release.home_page and release.home_page != 'UNKNOWN' %} 20 | {{ release.version }} home_page 21 | {% endif %} 22 | {% if release.download_url and release.download_url != 'UNKNOWN' %} 23 | {{ release.version }} download_url 24 | {% endif %} 25 | {% endfor %} 26 | {% endfor %} 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/localshop/templates/packages/simple_package_list.html: -------------------------------------------------------------------------------- 1 | Simple Index 2 | {% for package in packages %} 3 | {{ package.name }}
4 | {% endfor %} 5 | 6 | -------------------------------------------------------------------------------- /src/localshop/templates/partial/_form_field.html: -------------------------------------------------------------------------------- 1 | {% load widget_tweaks %} 2 | {% load forms %} 3 | 4 | {% if field.is_hidden %} 5 | {{ field }} 6 | {% else %} 7 | {% comment %} 8 | Make the field widget type available to templates so we can mark-up 9 | checkboxes differently to other widgets. 10 | {% endcomment %} 11 | 12 | {% block control_group %} 13 |
14 | 15 | {% block label %} 16 | {% if not nolabel and field|widget_type != 'checkboxinput' %} 17 | 21 | {% endif %} 22 | {% endblock %} 23 | 24 | {% block controls %} 25 |
26 | {% block widget %} 27 | {% if field|widget_type == 'checkboxinput' %} 28 | 32 | {% elif field|widget_type == 'radioselect' %} 33 | 36 | {% else %} 37 | {% render_field field class+="form-control" %} 38 | {% endif %} 39 | {% endblock %} 40 | 41 | {% block errors %} 42 | {% for error in field.errors %} 43 | {{ error }} 44 | {% endfor %} 45 | {% endblock %} 46 | 47 | {% block help_text %} 48 | {% if field.help_text %} 49 | 50 | {# We allow HTML within form help fields #} 51 | {{ field.help_text|safe }} 52 | 53 | {% endif %} 54 | {% endblock %} 55 |
56 | {% endblock %} 57 |
58 | {% endblock %} 59 | {% endif %} 60 | 61 | -------------------------------------------------------------------------------- /src/localshop/templates/partial/_form_fields.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if form.is_bound and not form.is_valid %} 4 |
5 | {% trans "Oops! We found some errors" %} - {% trans "please check the error messages below and try again" %} 6 |
7 | {% endif %} 8 | 9 | {% if form.non_field_errors %} 10 | {% for error in form.non_field_errors %} 11 |
12 | {{ error }} 13 |
14 | {% endfor %} 15 | {% endif %} 16 | 17 | {% for field in form %} 18 | {% include 'partial/_form_field.html' with field=field style=style %} 19 | {% endfor %} 20 | 21 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% trans "You are now logged out." %}

6 | {% trans 'Log in again' %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.main.html' %} 2 | 3 | {% load i18n %} 4 | 5 | 6 | {% block title %}{% trans "Change password" %}{% endblock %} 7 | 8 | 9 | {% block content %} 10 |

{% trans "Change password" %}

11 |

{% trans "From now on you can use your new password to signin" %}.

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_panel %} 5 |
6 |
Change password
7 |
8 |
9 | {% include "partial/_form_fields.html" %} 10 | {% csrf_token %} 11 | 12 |
13 | 14 |
15 |
16 |
17 |
18 | {% endblock %} 19 | 20 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.base.html' %} 2 | 3 | {% load i18n %} 4 | 5 | 6 | {% block title %}{{ title }}{% endblock %} 7 | 8 | 9 | {% block content %} 10 |

{{ title }}

11 |

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

12 |

{% trans 'Log in' %}

13 | {% endblock %} 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.base.html' %} 2 | 3 | {% load i18n %} 4 | 5 | 6 | {% block content %} 7 | 8 |
9 | 10 |
11 | {% trans "Change Password" %} 12 | {% include "partial/_form_field.html" with field=form.new_password1 %} 13 | {% include "partial/_form_field.html" with field=form.new_password2 %} 14 | {% csrf_token %} 15 |
16 | 17 |
18 | 19 |
20 |
21 | {% endblock %} 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/localshop/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block content %} 6 |

{{ title }}

7 |

{% trans "We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly." %}

8 |

{% trans "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}

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

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

9 |
10 | {% endif %} 11 |
{% csrf_token %} 12 |
13 | {% trans "Password reset" %} 14 |
15 |
16 | {% trans "Please enter your email address." %} 17 |
18 |
19 | {% for field in form %} 20 | {% include 'partial/_form_field.html' %} 21 | {% endfor %} 22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /src/localshop/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/src/localshop/templatetags/__init__.py -------------------------------------------------------------------------------- /src/localshop/templatetags/forms.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def form_widget(field): 8 | return field.field.widget.__class__.__name__ 9 | -------------------------------------------------------------------------------- /src/localshop/templatetags/permission_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def is_owner_of(user, repository): 8 | return repository.check_user_role(user, ['owner']) 9 | -------------------------------------------------------------------------------- /src/localshop/urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.conf.urls import include, url 5 | from django.conf.urls.static import static 6 | from django.contrib import admin 7 | from django.views.generic import RedirectView 8 | 9 | import localshop.apps.dashboard.urls 10 | import localshop.apps.packages.urls 11 | from localshop import views 12 | from localshop.apps.packages.views import SimpleIndex 13 | from localshop.apps.packages.xmlrpc import handle_request 14 | 15 | admin.autodiscover() 16 | 17 | static_prefix = re.escape(settings.STATIC_URL.lstrip('/')) 18 | 19 | 20 | urlpatterns = [ 21 | url(r'^$', views.index, name='index'), 22 | url(r'^dashboard/', include(localshop.apps.dashboard.urls)), 23 | 24 | # Default path for xmlrpc calls 25 | url(r'^RPC2$', handle_request), 26 | url(r'^pypi$', handle_request), 27 | 28 | url(r'^repo/', include(localshop.apps.packages.urls)), 29 | 30 | # Backwards compatible url (except for POST requests) 31 | url(r'^simple/?$', SimpleIndex.as_view(), {'repo': 'default'}), 32 | url(r'^simple/(?P.*)', 33 | RedirectView.as_view(url='/repo/default/%(path)s')), 34 | 35 | url(r'^oauth/', include('social_django.urls', namespace='social')), 36 | url(r'^accounts/', include('localshop.apps.accounts.auth_urls')), 37 | url(r'^accounts/', include('localshop.apps.accounts.urls')), 38 | url(r'^admin/', admin.site.urls), 39 | ] 40 | -------------------------------------------------------------------------------- /src/localshop/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | 4 | from django.core.cache import cache 5 | 6 | 7 | def generate_key(function, *args, **kwargs): 8 | args_string = ','.join([str(arg) for arg in args] + 9 | ['{}={}'.format(k, v) for k, v in kwargs.items()]) 10 | return '{}({})'.format(function.__name__, args_string) 11 | 12 | 13 | def enqueue(function, *args, **kwargs): 14 | key = generate_key(function, *args, **kwargs) 15 | logging.info('Scheduling task %s', key) 16 | 17 | if cache.get(key): 18 | logging.info('Dropping task %s', key) 19 | return 20 | 21 | cache.set(key, 'lock') 22 | function.delay(*args, **kwargs) 23 | 24 | 25 | def no_duplicates(function, *args, **kwargs): 26 | """ 27 | Makes sure that no duplicated tasks are enqueued. 28 | """ 29 | @wraps(function) 30 | def wrapper(self, *args, **kwargs): 31 | key = generate_key(function, *args, **kwargs) 32 | try: 33 | function(self, *args, **kwargs) 34 | finally: 35 | logging.info('Removing key %s', key) 36 | cache.delete(key) 37 | 38 | return wrapper 39 | -------------------------------------------------------------------------------- /src/localshop/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from localshop.apps.packages import xmlrpc 5 | 6 | 7 | @csrf_exempt 8 | def index(request): 9 | if request.method == 'POST': 10 | return xmlrpc.handle_request(request) 11 | return redirect('dashboard:index') 12 | -------------------------------------------------------------------------------- /src/localshop/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'localshop.settings') 4 | 5 | from django.core.wsgi import get_wsgi_application # noqa isort:skip 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/tests/apps/__init__.py -------------------------------------------------------------------------------- /tests/apps/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/tests/apps/accounts/__init__.py -------------------------------------------------------------------------------- /tests/apps/accounts/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localshop.apps.accounts import forms 4 | from tests.factories import TeamFactory, TeamMemberFactory, UserFactory 5 | 6 | 7 | class TestAccessKeyForm: 8 | 9 | @pytest.mark.django_db 10 | def test_save(self): 11 | user = UserFactory() 12 | data = { 13 | 'comment': 'something', 14 | } 15 | form = forms.AccessKeyForm(data=data, user=user) 16 | assert form.is_valid() 17 | 18 | form.save() 19 | assert user.access_keys.count() == 1 20 | 21 | key = user.access_keys.first() 22 | assert key.comment == 'something' 23 | 24 | 25 | class TestTeamMemberAddForm: 26 | @pytest.mark.django_db 27 | def test_save(self): 28 | user = UserFactory() 29 | team = TeamFactory() 30 | 31 | data = { 32 | 'user': user.pk, 33 | 'role': 'owner', 34 | } 35 | form = forms.TeamMemberAddForm(data=data, team=team) 36 | assert form.is_valid() 37 | 38 | form.save() 39 | assert team.users.count() == 1 40 | 41 | member = team.members.first() 42 | assert member.user == user 43 | assert member.role == 'owner' 44 | 45 | @pytest.mark.django_db 46 | def test_duplicate(self): 47 | user = UserFactory() 48 | team = TeamFactory() 49 | TeamMemberFactory(user=user, team=team, role='owner') 50 | 51 | data = { 52 | 'user': user.pk, 53 | 'role': 'owner', 54 | } 55 | form = forms.TeamMemberAddForm(data=data, team=team) 56 | assert not form.is_valid() 57 | 58 | 59 | class TestTeamMemberRemoveForm: 60 | @pytest.mark.django_db 61 | def test_save(self): 62 | user = UserFactory() 63 | team = TeamFactory() 64 | member = TeamMemberFactory(user=user, team=team, role='owner') 65 | 66 | data = { 67 | 'member_obj': member.pk 68 | } 69 | form = forms.TeamMemberRemoveForm(data=data, team=team) 70 | assert form.is_valid() 71 | 72 | cleaned_data = form.clean() 73 | assert cleaned_data == {'member_obj': member} 74 | 75 | @pytest.mark.django_db 76 | def test_invalid_team(self): 77 | user = UserFactory() 78 | team = TeamFactory() 79 | member = TeamMemberFactory(user=user, role='owner') 80 | 81 | data = { 82 | 'member_obj': member.pk 83 | } 84 | form = forms.TeamMemberRemoveForm(data=data, team=team) 85 | assert not form.is_valid() 86 | -------------------------------------------------------------------------------- /tests/apps/dashboard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/tests/apps/dashboard/__init__.py -------------------------------------------------------------------------------- /tests/apps/dashboard/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from localshop.apps.dashboard import forms 4 | from tests.factories import CredentialFactory, RepositoryFactory, TeamFactory 5 | 6 | 7 | class TestAccessControlForm(TestCase): 8 | def test_save_add(self): 9 | repository = RepositoryFactory() 10 | 11 | data = { 12 | 'label': 'all', 13 | 'cidr': '0/0', 14 | 'require_credentials': '1', 15 | } 16 | form = forms.AccessControlForm(data, repository=repository) 17 | assert form.is_valid() 18 | form.save() 19 | assert repository.cidr_list.count() == 1 20 | 21 | 22 | class TestRepositoryTeamForm(TestCase): 23 | def test_init(self): 24 | repository = RepositoryFactory() 25 | forms.RepositoryTeamForm(repository=repository) 26 | 27 | def test_save_add(self): 28 | team = TeamFactory() 29 | repository = RepositoryFactory() 30 | 31 | data = { 32 | 'team': team.pk, 33 | } 34 | form = forms.RepositoryTeamForm(data, repository=repository) 35 | assert form.is_valid() 36 | form.save() 37 | 38 | assert repository.teams.count() == 1 39 | 40 | def test_save_delete(self): 41 | team = TeamFactory() 42 | repository = RepositoryFactory() 43 | repository.teams.add(team) 44 | 45 | data = { 46 | 'team': team.pk, 47 | 'delete': '1', 48 | } 49 | form = forms.RepositoryTeamForm(data, repository=repository) 50 | assert form.is_valid() 51 | form.save() 52 | 53 | assert repository.teams.count() == 0 54 | 55 | 56 | class TestCredentialModelForm(TestCase): 57 | def test_save_new(self): 58 | repository = RepositoryFactory() 59 | 60 | data = { 61 | 'comment': 'some key', 62 | 'allow_upload': '', 63 | 'deactivated': '', 64 | } 65 | form = forms.CredentialModelForm(data, repository=repository) 66 | assert form.is_valid() 67 | form.save() 68 | 69 | assert repository.credentials.count() == 1 70 | credential = repository.credentials.first() 71 | 72 | assert credential.comment == 'some key' 73 | assert credential.allow_upload is False 74 | assert credential.deactivated is None 75 | 76 | def test_save_update(self): 77 | repository = RepositoryFactory() 78 | credential = CredentialFactory( 79 | repository=repository, allow_upload=True) 80 | 81 | data = { 82 | 'comment': 'some key', 83 | 'allow_upload': '1', 84 | 'deactivated': '1', 85 | } 86 | form = forms.CredentialModelForm( 87 | data, instance=credential, repository=repository) 88 | assert form.is_valid() 89 | form.save() 90 | 91 | assert repository.credentials.count() == 1 92 | credential = repository.credentials.first() 93 | 94 | assert credential.comment == 'some key' 95 | assert credential.allow_upload is True 96 | assert credential.deactivated is not None 97 | -------------------------------------------------------------------------------- /tests/apps/packages/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/apps/packages/test_pypi.py: -------------------------------------------------------------------------------- 1 | from localshop.apps.packages import pypi 2 | 3 | 4 | def test_get_package_information(pypi_stub): 5 | assert pypi.get_package_information(pypi_stub, 'minibar') 6 | -------------------------------------------------------------------------------- /tests/apps/packages/views/test_simple_detail.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import pytest 4 | from django.urls import reverse 5 | 6 | from localshop.apps.packages.tasks import fetch_package 7 | from tests.factories import ReleaseFileFactory 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_success(django_app, admin_user, repository, pypi_stub): 12 | repository.upstream_pypi_url = pypi_stub 13 | repository.save() 14 | release_file = ReleaseFileFactory(release__package__repository=repository) 15 | 16 | response = django_app.get( 17 | reverse('packages:simple_detail', kwargs={ 18 | 'slug': release_file.release.package.name, 19 | 'repo': release_file.release.package.repository.slug 20 | }), user=admin_user) 21 | 22 | assert response.status_code == 200 23 | assert 'Links for test-package' in response.unicode_body 24 | 25 | a_elms = response.html.select('a') 26 | assert len(a_elms) == 1 27 | assert a_elms[0]['href'] == ( 28 | 'http://testserver/repo/default/download/test-package/1/' + 29 | 'test-1.0.0-sdist.zip#md5=62ecd3ee980023db87945470aa2b347b') 30 | assert a_elms[0]['rel'][0] == 'package' 31 | 32 | 33 | @pytest.mark.django_db 34 | def test_missing_package_local_package(django_app, admin_user, repository, 35 | pypi_stub): 36 | repository.upstream_pypi_url = pypi_stub 37 | repository.save() 38 | 39 | fetch_package.run(repository.pk, 'minibar') 40 | 41 | response = django_app.get( 42 | reverse('packages:simple_detail', kwargs={ 43 | 'slug': 'minibar', 44 | 'repo': repository.slug, 45 | }), user=admin_user) 46 | 47 | assert response.status_code == 200 48 | assert 'Links for minibar' in response.unicode_body 49 | assert 'minibar-0.4.0-py2.py3-none-any.whl#md5=0bbdf41e028a4e6c75dfbd59660b6328' in response.unicode_body 50 | assert 'minibar-0.4.0.tar.gz#md5=a3768a7f948871d8e47b146053265100' in response.unicode_body 51 | assert 'minibar-0.1.tar.gz#md5=c935bfa49cb49e4f97fb8e24371105d7' in response.unicode_body 52 | 53 | 54 | @pytest.mark.django_db 55 | def test_nonexistent_package(django_app, admin_user, repository, pypi_stub): 56 | repository.upstream_pypi_url = pypi_stub 57 | repository.save() 58 | 59 | response = django_app.get( 60 | reverse('packages:simple_detail', kwargs={ 61 | 'slug': 'nonexistent', 62 | 'repo': repository.slug, 63 | }), user=admin_user) 64 | 65 | assert response.url == '%s/nonexistent' % pypi_stub 66 | assert response.status_code == 302 67 | 68 | 69 | @pytest.mark.django_db 70 | def test_wrong_package_name_case(django_app, admin_user, repository, pypi_stub): 71 | repository.upstream_pypi_url = pypi_stub 72 | repository.save() 73 | 74 | ReleaseFileFactory( 75 | release__package__repository=repository, 76 | release__package__name='minibar') 77 | 78 | response = django_app.get( 79 | reverse('packages:simple_detail', kwargs={ 80 | 'slug': 'Minibar', 81 | 'repo': 'default' 82 | }), user=admin_user) 83 | 84 | assert response.status_code == 302 85 | assert response.url == '/repo/default/minibar/' 86 | -------------------------------------------------------------------------------- /tests/apps/permissions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvantellingen/localshop/875ae6d056282bb9d33c07ab69d7bae8e02d5d66/tests/apps/permissions/__init__.py -------------------------------------------------------------------------------- /tests/apps/permissions/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from localshop.apps.permissions import models 4 | from tests.factories import CIDRFactory 5 | 6 | 7 | class CidrTest(TestCase): 8 | def test_has_access(self): 9 | self.assertFalse(models.CIDR.objects.has_access('192.168.1.1')) 10 | 11 | def test_simple(self): 12 | cidr = CIDRFactory(cidr='192.168.1.1', require_credentials=True) 13 | assert cidr.repository.cidr_list.has_access('192.168.1.1') 14 | 15 | def test_cidr(self): 16 | cidr = CIDRFactory(cidr='192.168.1.0/24', require_credentials=True) 17 | assert cidr.repository.cidr_list.has_access('192.168.1.1') 18 | -------------------------------------------------------------------------------- /tests/apps/permissions/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.test.client import RequestFactory 4 | from django.test.utils import override_settings 5 | 6 | from localshop.apps.permissions.utils import credentials_required 7 | from tests.factories import CIDRFactory 8 | 9 | 10 | @credentials_required 11 | def myview(request): 12 | return HttpResponse('ok') 13 | 14 | 15 | class ProxiedIPText(TestCase): 16 | def setUp(self): 17 | CIDRFactory(cidr='192.168.1.1', require_credentials=False) 18 | self.factory = RequestFactory() 19 | 20 | def test_disabled(self): 21 | # never look at X-Forwarded-For if the setting is disabled (default) 22 | req = self.factory.get('/', REMOTE_ADDR='192.168.1.1', HTTP_X_FORWARDED_FOR='10.1.1.1') 23 | resp = myview(req) 24 | self.assertEqual(resp.status_code, 200) 25 | 26 | req = self.factory.get('/', REMOTE_ADDR='127.0.0.1', HTTP_X_FORWARDED_FOR='192.168.1.1') 27 | resp = myview(req) 28 | self.assertEqual(resp.status_code, 403) 29 | 30 | req = self.factory.get('/', REMOTE_ADDR='10.1.1.1', HTTP_X_FORWARDED_FOR='192.168.1.1') 31 | resp = myview(req) 32 | self.assertEqual(resp.status_code, 403) 33 | 34 | def test_proxied(self): 35 | with override_settings(LOCALSHOP_USE_PROXIED_IP=True): 36 | req = self.factory.get('/', REMOTE_ADDR='127.0.0.1', HTTP_X_FORWARDED_FOR='192.168.1.1') 37 | resp = myview(req) 38 | self.assertEqual(resp.status_code, 200) 39 | 40 | req = self.factory.get('/', REMOTE_ADDR='192.168.1.1', HTTP_X_FORWARDED_FOR='10.1.1.1') 41 | resp = myview(req) 42 | self.assertEqual(resp.status_code, 403) 43 | 44 | def test_proxied_last(self): 45 | # only the last hop counts (first in the list) 46 | with override_settings(LOCALSHOP_USE_PROXIED_IP=True): 47 | req = self.factory.get('/', REMOTE_ADDR='127.0.0.1', HTTP_X_FORWARDED_FOR='192.168.1.1, 10.1.1.1') 48 | resp = myview(req) 49 | self.assertEqual(resp.status_code, 200) 50 | 51 | req = self.factory.get('/', REMOTE_ADDR='127.0.0.1', HTTP_X_FORWARDED_FOR='10.1.1.1, 192.168.1.1') 52 | resp = myview(req) 53 | self.assertEqual(resp.status_code, 403) 54 | 55 | def test_proxied_mising(self): 56 | with override_settings(LOCALSHOP_USE_PROXIED_IP=True): 57 | req = self.factory.get('/', REMOTE_ADDR='127.0.0.1') 58 | resp = myview(req) 59 | self.assertEqual(resp.status_code, 403) 60 | 61 | # only uses the header, never falls back to REMOTE_ADDR 62 | req = self.factory.get('/', REMOTE_ADDR='192.168.1.1') 63 | resp = myview(req) 64 | self.assertEqual(resp.status_code, 403) 65 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import pytest 5 | import requests_mock 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.contrib.messages.storage.fallback import FallbackStorage 8 | from django.contrib.sessions.backends.db import SessionStore 9 | from django.test.client import RequestFactory as BaseRequestFactory 10 | from django.test.utils import override_settings 11 | 12 | from localshop.apps.packages.pypi import get_search_names 13 | from tests.factories import CIDRFactory, RepositoryFactory 14 | 15 | 16 | def pytest_configure(config): 17 | override = override_settings( 18 | ALLOWED_HOSTS=['*'], 19 | STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage', 20 | CELERY_TASK_ALWAYS_EAGER=True, 21 | ) 22 | override.enable() 23 | 24 | 25 | @pytest.fixture(scope='function') 26 | def pypi_stub(): 27 | with requests_mock.Mocker(real_http=True) as rm: 28 | wildcard_re = re.compile('^https://pypi\.internal/.*') 29 | rm.register_uri('GET', wildcard_re, status_code=404) 30 | 31 | pypi_dir = os.path.join(os.path.dirname(__file__), 'pypi_data') 32 | for filename in os.listdir(pypi_dir): 33 | with open(os.path.join(pypi_dir, filename), 'rb') as fh: 34 | content = fh.read() 35 | 36 | name, ext = os.path.splitext(filename) 37 | url = 'https://pypi.internal/pypi/%s/json' % name 38 | rm.register_uri('GET', url, content=content) 39 | 40 | # Register the alternative urls and redirect to original url 41 | for alt_name in get_search_names(name): 42 | if alt_name != name: 43 | alt_url = 'https://pypi.internal/pypi/%s/json' % alt_name 44 | rm.register_uri( 45 | 'GET', 46 | alt_url, 47 | headers={ 48 | 'Location': url, 49 | }, 50 | status_code=301) 51 | 52 | yield 'https://pypi.internal/pypi/' 53 | 54 | 55 | @pytest.fixture(scope='function') 56 | @pytest.mark.django_db 57 | def repository(db): 58 | repo = RepositoryFactory() 59 | CIDRFactory(repository=repo) 60 | return repo 61 | 62 | 63 | class RequestFactory(BaseRequestFactory): 64 | 65 | def request(self, user=None, **request): 66 | request = super(RequestFactory, self).request(**request) 67 | request.user = AnonymousUser() 68 | request.session = SessionStore() 69 | request._messages = FallbackStorage(request) 70 | return request 71 | 72 | 73 | @pytest.fixture() 74 | def rf(): 75 | return RequestFactory() 76 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | 4 | from localshop.apps.accounts.models import Team, TeamMember 5 | from localshop.apps.packages.models import ( 6 | Package, Release, ReleaseFile, Repository) 7 | from localshop.apps.permissions.models import CIDR, Credential 8 | 9 | 10 | class RepositoryFactory(factory.DjangoModelFactory): 11 | name = 'Default' 12 | slug = 'default' 13 | 14 | class Meta: 15 | model = Repository 16 | django_get_or_create = ('slug',) 17 | 18 | 19 | class PackageFactory(factory.DjangoModelFactory): 20 | name = 'test-package' 21 | repository = factory.SubFactory(RepositoryFactory) 22 | 23 | class Meta: 24 | model = Package 25 | 26 | 27 | class ReleaseFactory(factory.DjangoModelFactory): 28 | author = 'John Doe' 29 | author_email = 'j.doe@example.org' 30 | description = 'A test release' 31 | download_url = 'http://www.example.org/download' 32 | home_page = 'http://www.example.org' 33 | license = 'BSD' 34 | metadata_version = '1.0' 35 | package = factory.SubFactory(PackageFactory) 36 | summary = 'Summary of the test package' 37 | version = '1.0.0' 38 | 39 | class Meta: 40 | model = Release 41 | 42 | 43 | class ReleaseFileFactory(factory.DjangoModelFactory): 44 | release = factory.SubFactory(ReleaseFactory) 45 | distribution = factory.django.FileField(filename='the_file.dat', 46 | data='the file data') 47 | size = 1120 48 | filetype = 'sdist' 49 | filename = factory.LazyAttribute(lambda a: 'test-%s-%s.zip' % ( 50 | a.release.version, a.filetype)) 51 | md5_digest = '62ecd3ee980023db87945470aa2b347b' 52 | python_version = '2.7' 53 | url = factory.LazyAttribute(lambda a: ( 54 | 'http://www.example.org/download/%s' % a.filename)) 55 | 56 | class Meta: 57 | model = ReleaseFile 58 | 59 | 60 | class CIDRFactory(factory.DjangoModelFactory): 61 | repository = factory.SubFactory(RepositoryFactory) 62 | cidr = '0/0' 63 | require_credentials = False 64 | 65 | class Meta: 66 | model = CIDR 67 | 68 | 69 | class CredentialFactory(factory.DjangoModelFactory): 70 | class Meta: 71 | model = Credential 72 | 73 | 74 | class TeamFactory(factory.DjangoModelFactory): 75 | class Meta: 76 | model = Team 77 | 78 | 79 | class UserFactory(factory.DjangoModelFactory): 80 | class Meta: 81 | model = get_user_model() 82 | 83 | 84 | class TeamMemberFactory(factory.DjangoModelFactory): 85 | team = factory.SubFactory(TeamFactory) 86 | user = factory.SubFactory(UserFactory) 87 | role = 'developer' 88 | 89 | class Meta: 90 | model = TeamMember 91 | -------------------------------------------------------------------------------- /tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import os 3 | 4 | import mock 5 | 6 | from django.core import management 7 | 8 | TEST_HOME = os.path.join(os.path.dirname(__file__), 'home') 9 | MOCK_ENV = {'LOCALSHOP_HOME': TEST_HOME} 10 | call_command_real = deepcopy(management.call_command) 11 | const_mock = mock.MagicMock() 12 | 13 | 14 | class CommonCommandsTestMixin(object): 15 | 16 | def setUp(self): 17 | super(CommonCommandsTestMixin, self).setUp() 18 | 19 | patch_call_command = mock.patch('django.core.management.call_command', 20 | const_mock) 21 | 22 | self.mock_call = patch_call_command.start() 23 | 24 | self.addCleanup(patch_call_command.stop) 25 | self.addCleanup(self.mock_call.reset_mock) 26 | 27 | self.assertNotEqual(self.mock_call, call_command_real) 28 | -------------------------------------------------------------------------------- /tests/management/test_command_init.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import mock 4 | from django.test import TestCase 5 | 6 | from . import call_command_real as call_command 7 | from . import CommonCommandsTestMixin 8 | 9 | DEFAULT_PATH = os.path.expanduser('~/.localshop') 10 | 11 | 12 | class TestInitCommand(CommonCommandsTestMixin, TestCase): 13 | def setUp(self): 14 | super(TestInitCommand, self).setUp() 15 | 16 | patch_mkdir = mock.patch('os.mkdir') 17 | patch_open = mock.patch('localshop.management.commands.init.open', 18 | mock.mock_open(), create=True) 19 | 20 | self.mock_mkdir = patch_mkdir.start() 21 | self.mock_open = patch_open.start() 22 | 23 | self.addCleanup(patch_mkdir.stop) 24 | self.addCleanup(patch_open.stop) 25 | 26 | def test_default_commands_called(self): 27 | call_command('init') 28 | self.assertEqual(self.mock_call.call_count, 2) 29 | self.mock_call.assert_any_call( 30 | 'migrate', database='default', interactive=False) 31 | -------------------------------------------------------------------------------- /tests/management/test_runner.py: -------------------------------------------------------------------------------- 1 | from localshop.runner import main 2 | 3 | 4 | def test_main(monkeypatch): 5 | from django.core import management 6 | 7 | def mock_exec(args): 8 | return 9 | 10 | monkeypatch.setattr(management, 'execute_from_command_line', mock_exec) 11 | 12 | main() 13 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.six import BytesIO 2 | 3 | 4 | class NamedStringIO(BytesIO): 5 | 6 | """A StringIO that has a name in it""" 7 | 8 | def __init__(self, *args, **kwargs): 9 | self.name = kwargs.pop('name') 10 | super(NamedStringIO, self).__init__(*args, **kwargs) 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | 5 | [testenv] 6 | setenv = 7 | DJANGO_SETTINGS_MODULE = localshop.settings 8 | 9 | commands = 10 | pip install -e .[test] 11 | py.test {posargs:tests --cov localshop --cov-report term-missing} 12 | 13 | 14 | [testenv:docs] 15 | changedir=docs 16 | whitelist_externals=make 17 | deps = 18 | sphinx==1.3.1 19 | sphinx-autobuild==0.5.2 20 | commands = 21 | make clean html 22 | --------------------------------------------------------------------------------