├── .env.dist ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ ├── test.yaml │ └── upload_to_dockerhub.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.prod ├── INSTALLATION.md ├── LICENSE ├── Makefile ├── README.md ├── community ├── __init__.py ├── context_processors.py ├── templates │ ├── _tags_filtering_form.html │ ├── account │ │ └── custom_captcha.html │ ├── base.html │ ├── base_site.html │ ├── community │ │ └── index.html │ ├── confirm_delete.html │ ├── footer.html │ ├── header.html │ └── search_form.html ├── templatetags │ ├── __init__.py │ └── devtags.py ├── tests │ ├── __init__.py │ └── test_homepage.py ├── urls.py └── views.py ├── dev_requirements.txt ├── docker-compose.yml ├── events ├── __init__.py ├── admin.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20170325_2011.py │ ├── 0003_eventparticipation_gender.py │ ├── 0004_auto_20180428_1949.py │ ├── 0005_auto_20180429_1546.py │ ├── 0006_alter_event_slug.py │ ├── 0007_alter_eventparticipation_seniority.py │ └── __init__.py ├── mixins.py ├── models.py ├── templatetags │ ├── __init__.py │ └── event_tags.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_event_participation.py │ ├── test_view_report_event.py │ └── test_views.py ├── urls.py └── views.py ├── fixtures ├── initial_data.json └── permission.json ├── initialize.sh ├── joboffers ├── __init__.py ├── admin.py ├── apps.py ├── constants.py ├── forms.py ├── joboffer_actions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── create_analytics_sample_data.py │ │ ├── create_facebook_page_token.py │ │ ├── expire_old_offers.py │ │ ├── notify_pending_moderation_offers.py │ │ ├── test_discourse.py │ │ ├── test_facebook.py │ │ ├── test_mastodon.py │ │ ├── test_moderation_notification.py │ │ ├── test_telegram.py │ │ └── test_twitter.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20211102_1725.py │ ├── 0003_auto_20211125_1435.py │ ├── 0004_joboffer_short_description.py │ ├── 0005_alter_joboffer_short_description.py │ ├── 0006_auto_20220110_1801.py │ ├── 0007_auto_20220216_1022.py │ ├── 0008_auto_20220304_1609.py │ ├── 0009_alter_joboffer_description.py │ ├── 0010_auto_20220311_1001.py │ ├── 0011_rename_joboffervisualization_jobofferaccesslog.py │ ├── 0012_auto_20220413_1659.py │ ├── 0013_alter_joboffer_title.py │ ├── 0014_alter_jobofferaccesslog_options.py │ ├── 0015_alter_joboffer_company.py │ └── __init__.py ├── models.py ├── publishers │ ├── __init__.py │ ├── discourse │ │ ├── __init__.py │ │ └── template.html │ ├── facebook │ │ ├── __init__.py │ │ └── template.html │ ├── mastodon │ │ ├── __init__.py │ │ └── template.html │ ├── telegram │ │ ├── __init__.py │ │ └── template.html │ ├── template.html │ └── twitter │ │ ├── __init__.py │ │ └── template.html ├── telegram_api.py ├── templates │ └── joboffers │ │ ├── _paginator.html │ │ ├── _tags.html │ │ ├── history.html │ │ ├── history │ │ ├── _comment.html │ │ ├── _create.html │ │ ├── _header.html │ │ └── _update.html │ │ ├── joboffer_admin.html │ │ ├── joboffer_analytics.html │ │ ├── joboffer_detail.html │ │ ├── joboffer_form.html │ │ ├── joboffer_history.html │ │ ├── joboffer_list.html │ │ ├── joboffer_overview.html │ │ └── joboffer_reject.html ├── templatetags │ ├── __init__.py │ └── history.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── fixtures.py │ ├── joboffers_descriptions.py │ ├── test_discourse_publisher.py │ ├── test_expire_old_offers.py │ ├── test_facebook_publisher.py │ ├── test_filters.py │ ├── test_joboffer_actions.py │ ├── test_joboffer_publisher.py │ ├── test_joboffer_validations.py │ ├── test_mastodon_publisher.py │ ├── test_models.py │ ├── test_notify-pending_moderation_offers.py │ ├── test_telegram_api.py │ ├── test_twitter_publisher.py │ ├── test_utils.py │ ├── test_views.py │ └── utils.py ├── urls.py ├── utils.py └── views.py ├── manage.py ├── news ├── __init__.py ├── admin.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ └── factories.py ├── urls.py └── views.py ├── prod_requirements.txt ├── pull_request_template.md ├── pyarweb ├── __init__.py ├── celery.py ├── formats │ ├── __init__.py │ └── es_AR │ │ ├── __init__.py │ │ └── formats.py ├── forms.py ├── gunicorn_cfg.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── development │ │ └── __init__.py │ ├── production │ │ └── __init__.py │ └── staging │ │ └── __init__.py ├── tests │ └── fixtures.py ├── urls.py ├── views.py └── wsgi.py ├── pycompanies ├── __init__.py ├── admin.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_company_rank.py │ ├── 0003_usercompanyprofile.py │ ├── 0004_alter_usercompanyprofile_user.py │ ├── 0005_company_owner_to_usercompanyprofile.py │ ├── 0006_remove_company_owner.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── fixtures.py │ └── test_views.py ├── urls.py └── views.py ├── pytest.ini ├── requirements.txt ├── static ├── bootstrap │ └── 3.1.1 │ │ ├── css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ └── bootstrap.min.css │ │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ │ └── js │ │ ├── bootstrap-growl.min.js │ │ ├── bootstrap.js │ │ └── bootstrap.min.js ├── css │ ├── animate.css │ ├── event_map.css │ ├── search.css │ └── styles.css ├── img │ ├── azure-footer.png │ ├── banner.png │ ├── icons │ │ ├── icons.png │ │ ├── icons_active.png │ │ └── pyar.ico │ ├── logo-header.png │ ├── moin-www.png │ ├── pyar-footer.png │ ├── python-banner.png │ ├── python-footer.png │ └── ribbon.png ├── jquery-autosuggest │ ├── css │ │ ├── autoSuggest-grappelli.css │ │ └── autoSuggest.css │ └── js │ │ └── jquery.autoSuggest.minified.js ├── jquery │ └── 1.11.0 │ │ └── jquery.min.js └── js │ ├── joboffer_track.js │ ├── tag_filtering.js │ └── web.js └── templates ├── 403.html ├── 404.html ├── 500.html ├── account ├── base.html ├── email_confirm.html ├── login.html ├── logout.html ├── password_reset.html ├── password_reset_done.html ├── password_reset_from_key.html ├── password_reset_from_key_done.html ├── signup.html ├── signup_closed.html └── verification_sent.html ├── buscador.html ├── companies ├── _user_actions.html ├── company_admin.html ├── company_analytics.html ├── company_association_list.html ├── company_confirm_delete.html ├── company_confirm_disassociate.html ├── company_detail.html ├── company_form.html └── company_list.html ├── email_confirm_la ├── email │ ├── email_confirmation_message.html │ └── email_confirmation_subject.txt ├── email_confirmation_expiration.html ├── email_confirmation_fail.html └── email_confirmation_success.html ├── events ├── event_confirm_delete.html ├── event_detail.html ├── event_detail_body.html ├── event_form.html ├── event_list.html ├── eventparticipation_confirm_delete.html ├── eventparticipation_form.html ├── eventparticipation_list.html └── next_events.html ├── irc └── irc.html ├── jobs ├── _jobs_tags.html ├── inactivate_job_email.txt ├── job_confirm_delete.html ├── job_detail.html ├── job_detail_feed.html ├── job_form.html ├── job_inactivate_form.html ├── job_list.html ├── job_overview.html └── jobs_by_user.html ├── news ├── _news_tags.html ├── newsarticle_confirm_delete.html ├── newsarticle_detail.html ├── newsarticle_form.html └── newsarticle_list.html ├── registration ├── activate.html ├── activation_complete.html ├── activation_email.txt ├── activation_email_subject.txt ├── login.html ├── logout.html ├── registration_complete.html └── registration_form.html ├── special_page.html └── waliki ├── 403.html ├── detail.html └── whatchanged.html /.env.dist: -------------------------------------------------------------------------------- 1 | # Copy as .env and replace with correct values 2 | TELEGRAM_BOT_TOKEN=xxxx 3 | TELEGRAM_MODERATORS_CHAT_ID=xxx 4 | TELEGRAM_PUBLIC_CHAT_ID=xxx 5 | FACEBOOK_PAGE_ACCESS_TOKEN=xxx 6 | FACEBOOK_PAGE_ID=xxx 7 | TWITTER_ACCESS_TOKEN=xxx 8 | TWITTER_ACCESS_SECRET=xxx 9 | TWITTER_CONSUMER_KEY=xxx 10 | TWITTER_CONSUMER_SECRET=xxx 11 | DISCOURSE_HOST=example.com 12 | DISCOURSE_API_KEY=abcdef1235 13 | DISCOURSE_USERNAME=xxxx 14 | DISCOURSE_CATEGORY=1 15 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=99 3 | exclude=migrations, .git, build.sh 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Informe de errores 2 | description: Crea un informe de errores para ayudarnos a mejorar. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Gracias por tomarse el tiempo de rellenar un informe de error. Recuerde rellenar todos los campos, incluido el título. 8 | - type: textarea 9 | id: current 10 | attributes: 11 | label: Comportamiento actual 12 | description: | 13 | Describa aquí el problema o la cuestión. 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: expected 18 | attributes: 19 | label: Comportamiento esperado 20 | description: | 21 | Díganos qué debería pasar en su lugar. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: reproduce 26 | attributes: 27 | label: Pasos para reproducir el problema 28 | description: | 29 | ¿Qué tiene que hacer alguien más para encontrarse con el mismo fallo? 30 | placeholder: | 31 | 1. Paso 1 32 | 2. Paso 2 33 | 3. Paso 3 34 | 4. ... 35 | validations: 36 | required: true 37 | - type: textarea 38 | id: extra 39 | attributes: 40 | label: Notas adicionales 41 | description: | 42 | ¿Tiene alguna nota extra que pueda ayudar a resolver el problema y que no encaje en ningún otro campo? 43 | placeholder: | 44 | No 45 | validations: 46 | required: false 47 | - type: textarea 48 | id: commit 49 | attributes: 50 | label: pyAr rev. hash/commit 51 | description: | 52 | ¿Qué commit estaba utilizando cuando obtuvo el problema? (en caso de no ser el ultimo) 53 | validations: 54 | required: true 55 | - type: input 56 | id: os 57 | attributes: 58 | label: Sistema operativo 59 | description: | 60 | Sistema operativo, es decir, Windows 10 x64, Debian 10 x64, etc. 61 | validations: 62 | required: true 63 | - type: markdown 64 | attributes: 65 | value: | 66 | Gracias por su contribución. 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | # Service containers to run with `container-job` 10 | services: 11 | # Label used to access the service container 12 | postgres2: 13 | # Docker Hub image 14 | image: postgres:11.5 15 | # Provide the password for postgres 16 | env: 17 | POSTGRES_PASSWORD: somepassword 18 | POSTGRES_DB: testing_db 19 | ports: 20 | - 5432:5432 21 | # Set health checks to wait until postgres has started 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | strategy: 28 | matrix: 29 | python-version: [3.9] 30 | env: 31 | DJANGO: 3.0 32 | SECRET_KEY: supersecret 33 | DB_PORT: 5432 34 | POSTGRES_HOST: localhost 35 | POSTGRES_PASSWORD: somepassword 36 | POSTGRES_USER: postgres 37 | POSTGRES_DB: testing_db 38 | DJANGO_SETTINGS_MODULE: pyarweb.settings.development 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | 47 | - name: Install system libraries 48 | run: | 49 | sudo apt-get install -y libpq-dev libxml2-dev libxslt1-dev 50 | 51 | - name: Cache dependencies 52 | uses: actions/cache@v3 53 | with: 54 | path: ~/.cache/pip 55 | key: ${{ runner.os }}-pip-${{ hashFiles('**/dev_requirements.txt') }} 56 | restore-keys: | 57 | ${{ runner.os }}-pip- 58 | 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install -r dev_requirements.txt 63 | 64 | - name: Check Code Style 65 | uses: pre-commit/action@v3.0.1 66 | 67 | - name: Check for Django migrations 68 | run: | 69 | python manage.py makemigrations --dry-run --check 70 | 71 | - name: Run migrations 72 | run: | 73 | python manage.py migrate 74 | 75 | - name: Run tests 76 | run: | 77 | pytest -v 78 | -------------------------------------------------------------------------------- /.github/workflows/upload_to_dockerhub.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | release: 14 | types: [published] 15 | 16 | jobs: 17 | push_to_registry: 18 | name: Push Docker image to Docker Hub 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_PASSWORD }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 33 | with: 34 | images: my-docker-hub-namespace/my-docker-hub-repository 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 38 | with: 39 | context: . 40 | file: ./Dockerfile.prod 41 | push: true 42 | tags: ${{ steps.meta.outputs.tags }} 43 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | ### PyAR Stuff 98 | *.pyc 99 | *.swp 100 | *.swo 101 | .venv 102 | .vscode/ 103 | db.sqlite3 104 | waliki_data 105 | waliki_attachments 106 | media 107 | .vagrant 108 | 109 | ### PyCharm stuff 110 | .idea/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-merge-conflict 7 | - id: debug-statements 8 | 9 | - repo: https://github.com/pycqa/flake8 10 | rev: 7.1.1 11 | hooks: 12 | - id: flake8 13 | args: 14 | - '--config=.flake8' 15 | - '--exclude=.venv,**/migrations/*.py' 16 | 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: v3.19.1 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py39-plus] 22 | 23 | - repo: https://github.com/adamchainz/django-upgrade 24 | rev: "1.22.2" 25 | hooks: 26 | - id: django-upgrade 27 | args: [--target-version, "3.2"] 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Código de conducta 2 | 3 | Este repositorio adhiere a los principios y el Código de Conducta de la Asociación Civil Python Argentina, accesibles online en: https://ac.python.org.ar/#coc. Se espera que todos los contribuyentes de este repositorio lo cumplan, y los mecanismos para reportar problemas son los que ofrece dicha asociación en la misma url. Los administradores serán responsables de fomentar el cumplimiento de este código. 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | ENV PYTHONUNBUFFERED 1 3 | ENV PYTHONPATH /code:$PYTHONPATH 4 | RUN apt-get update && apt-get -y install flite=2.2-5 5 | RUN mkdir /code 6 | WORKDIR /code 7 | COPY dev_requirements.txt /code 8 | COPY requirements.txt /code 9 | RUN pip install -U pip 10 | RUN pip install -r requirements.txt 11 | RUN pip install -r dev_requirements.txt 12 | COPY . /code/ 13 | RUN pip install -r /code/prod_requirements.txt 14 | -------------------------------------------------------------------------------- /Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | ENV PYTHONUNBUFFERED 1 3 | ENV PYTHONPATH /code:$PYTHONPATH 4 | RUN mkdir /code 5 | WORKDIR /code 6 | COPY prod_requirements.txt /code 7 | COPY requirements.txt /code 8 | RUN pip install -U pip 9 | RUN pip install -r prod_requirements.txt 10 | COPY . /code/ 11 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | ## Guia de instalación 2 | 3 | Seguí los pasos en nuestra wiki: https://github.com/PyAr/pyarweb/wiki/Instalacion-con-Docker 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "help -- print this help" 3 | @echo "start -- start docker stack" 4 | @echo "stop -- stop docker stack" 5 | @echo "ps -- show status" 6 | @echo "clean -- clean all artifacts" 7 | @echo "test -- run tests using docker" 8 | @echo "dockershell -- run bash inside docker" 9 | @echo "shell_plus -- run django shell_plus inside docker" 10 | @echo "bootstrap --build containers, run django migrations, load fixtures and create the a superuser" 11 | 12 | start: 13 | docker build -t pyarweb . 14 | docker-compose up 15 | 16 | stop: 17 | docker-compose stop 18 | 19 | ps: 20 | docker-compose ps 21 | 22 | clean: stop 23 | docker-compose rm --force -v 24 | 25 | only_test: 26 | docker-compose run --rm web /usr/local/bin/pytest -v 27 | 28 | pep8: 29 | docker-compose run --rm web flake8 30 | 31 | format: 32 | pre-commit run -a 33 | 34 | test: pep8 only_test 35 | 36 | dockershell: 37 | docker-compose run --rm web /bin/bash 38 | 39 | migrations: 40 | docker-compose run --rm web python3 manage.py makemigrations 41 | 42 | migrate: 43 | docker-compose run --rm web python3 manage.py migrate --skip-checks 44 | 45 | shell_plus: 46 | docker-compose run --rm web python3 manage.py shell_plus 47 | 48 | .PHONY: help start stop ps clean test dockershell shell_plus only_test pep8 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/PyAr/pyarweb.png)](https://secure.travis-ci.org/PyAr/pyarweb.svg?branch=master) 2 | 3 | pyarweb 4 | ======= 5 | 6 | Esta es la implementación del [sitio para PyAr / Python Argentina](http://python.org.ar) hecha con Django 7 | por su comunidad, wooohooo! 8 | 9 | Decenas de personas han colaborado de [diversas maneras](https://github.com/PyAr/pyarweb/wiki/Contribuyendo-con-PyArWeb). 10 | ¡Gracias por eso! Y vos ¿te querés sumar? 11 | 12 | ## ¿Cómo arranco / instalo el proyecto en mi máquina? 13 | 14 | ### Vía virtualenv: 15 | 16 | Si querés podés instalar todo el entorno en tu propia maquina, podés ver 17 | instrucciones generales en [esta página de la wiki](https://github.com/PyAr/pyarweb/wiki/Instalaci%C3%B3n-manual) 18 | 19 | ### Via [Docker](http://docker.com): 20 | 21 | podés ver las instrucciones en [esta página de la wiki](https://github.com/PyAr/pyarweb/wiki/Instalacion-con-Docker) 22 | 23 | ## Lint & Format 24 | 25 | Para correr el linter y el formateador de código, podés correr: 26 | 27 | ```bash 28 | make format 29 | ``` 30 | 31 | ## Más info 32 | 33 | Para más información visitá (y contribuí!) a nuestra [wiki de desarrollo](https://github.com/PyAr/pyarweb/wiki) 34 | 35 | 36 | -------------------------------------------------------------------------------- /community/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/community/__init__.py -------------------------------------------------------------------------------- /community/context_processors.py: -------------------------------------------------------------------------------- 1 | def pyar_wiki_url(request): 2 | from django.conf import settings 3 | # return the value you want as a dictionnary. you may add multiple values in there. 4 | return { 5 | 'PYAR_WIKI_URL': settings.PYAR_WIKI_URL, 6 | 'GOOGLE_TRACKING_ID': settings.GOOGLE_TRACKING_ID, 7 | } 8 | -------------------------------------------------------------------------------- /community/templates/_tags_filtering_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | 3 |
4 |
5 |
6 |
7 | 8 | 9 | 12 | 13 |
14 |
15 | 20 |
21 |
22 |
23 |

{% trans 'Filtrar por etiqueta' %}

24 | 25 |
26 |
27 |
28 | {% for tag in usefultags %} 29 | 34 | 37 | {% endfor %} 38 |
39 |
40 |
41 |
42 |
43 | 44 | -------------------------------------------------------------------------------- /community/templates/account/custom_captcha.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% spaceless %} 3 |
4 | 5 |
6 |
7 |
8 | captcha 9 | {% if audio %} 10 |
19 | {% include "django/forms/widgets/multiwidget.html" %} 20 |
21 |
22 |
23 | {% endspaceless %} -------------------------------------------------------------------------------- /community/templates/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n devtags %} 3 | {% load event_tags %} 4 | 5 | {% block content %} 6 | 7 |
8 | {% block beforemain %}{% endblock %} 9 | 10 |
11 | 12 | {% block container %} 13 | 14 |
15 | 16 |
17 | {% block left-column %}{% endblock %} 18 |
19 | 20 |
21 | 22 | {% block right-column %} 23 | 24 | 25 | {% include 'search_form.html' %} 26 | 27 | 28 |
29 |
30 |

{% trans "Próximos Eventos" %}
31 | 32 | RSS 33 |

34 | 35 |
36 | 37 | {% next_events %} 38 | 39 |
40 | 41 | {% endblock %} 42 | 43 | 44 |
45 |
46 | 47 | {% endblock %} 48 |
49 | {% endblock %} 50 |
51 | -------------------------------------------------------------------------------- /community/templates/confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% load devtags %} 5 | 6 | {% block content %} 7 |
8 |
9 | 12 |
{% csrf_token %} 13 |

{% blocktrans %}¿Está seguro de que desear eliminar "{{ object }}"?{% endblocktrans %}

14 | 15 | {% trans 'Cancelar' %} 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /community/templates/footer.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /community/templates/search_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | 5 | 6 | 9 | 10 |
-------------------------------------------------------------------------------- /community/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/community/templatetags/__init__.py -------------------------------------------------------------------------------- /community/templatetags/devtags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from lxml import etree 3 | from django.contrib.auth.models import Group 4 | 5 | register = Library() 6 | 7 | 8 | @register.filter 9 | def get_range(value): 10 | """ 11 | Filter - returns a list containing range made from given value 12 | Usage (in template): 13 | 14 | 17 | 18 | Results with the HTML: 19 | 24 | 25 | Instead of 3 one may use the variable set in the views 26 | """ 27 | return range(value) 28 | 29 | 30 | @register.filter 31 | def html2text(html): 32 | if html: 33 | return etree.tostring( 34 | etree.HTML(html), 35 | encoding='utf8', 36 | method='text' 37 | ).decode('utf8') 38 | return '' 39 | 40 | 41 | @register.filter 42 | def has_group(user, group_name): 43 | group = Group.objects.get(name=group_name) 44 | return True if group in user.groups.all() else False 45 | -------------------------------------------------------------------------------- /community/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/community/tests/__init__.py -------------------------------------------------------------------------------- /community/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.conf.urls import patterns 3 | 4 | 5 | urlpatterns = patterns( 6 | '', 7 | re_path(r'^', 'community.views.homepage', name='homepage'), 8 | ) 9 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest==6.2.5 3 | pytest-django==4.4.0 4 | factory-boy==3.2.1 5 | requests-mock==1.9.3 6 | ipython==8.10.0 7 | ipdb==0.13.9 8 | flake8==4.0.1 9 | pre-commit==4.0.1 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | volumes: 4 | pgdata: 5 | 6 | services: 7 | postgres: 8 | restart: always 9 | image: postgres:11.5 10 | ports: 11 | - "5432:5432" 12 | volumes: 13 | - pgdata:/var/lib/postgresql/data/ 14 | environment: 15 | - POSTGRES_PASSWORD=secret 16 | 17 | web: 18 | image: pyar/pyarweb 19 | command: python3 manage.py runserver 0.0.0.0:8000 20 | volumes: 21 | - .:/code 22 | ports: 23 | - "8000:8000" 24 | links: 25 | - postgres 26 | depends_on: 27 | - postgres 28 | environment: 29 | - DB_USER=postgres 30 | - DB_NAME=postgres 31 | - DB_PASS=secret 32 | - DB_SERVICE=postgres 33 | - DJANGO_SETTINGS_MODULE=pyarweb.settings.development 34 | # These environment variables must be defined in a .env file or 35 | # from the shell before starting the containers 36 | - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} 37 | - TELEGRAM_MODERATORS_CHAT_ID=${TELEGRAM_MODERATORS_CHAT_ID} 38 | - TELEGRAM_PUBLIC_CHAT_ID=${TELEGRAM_PUBLIC_CHAT_ID} 39 | - FACEBOOK_PAGE_ACCESS_TOKEN=${FACEBOOK_PAGE_ACCESS_TOKEN} 40 | - FACEBOOK_PAGE_ID=${FACEBOOK_PAGE_ID} 41 | -------------------------------------------------------------------------------- /events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/events/__init__.py -------------------------------------------------------------------------------- /events/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Event 4 | 5 | admin.site.register(Event) 6 | -------------------------------------------------------------------------------- /events/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | from django.conf import settings 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Event', 14 | fields=[ 15 | ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), 16 | ('name', models.CharField(max_length=100, verbose_name='Título')), 17 | ('description', models.TextField(verbose_name='Descripcion')), 18 | ('place', models.CharField(max_length=100, verbose_name='Lugar')), 19 | ('address', models.CharField(max_length=100, verbose_name='Direccion')), 20 | ('url', models.URLField(null=True, blank=True)), 21 | ('start_at', models.DateTimeField(verbose_name='Comienza a las')), 22 | ('end_at', models.DateTimeField(verbose_name='Termina a las')), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('updated_at', models.DateTimeField(auto_now=True)), 25 | ('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /events/migrations/0003_eventparticipation_gender.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('events', '0002_auto_20170325_2011'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name='eventparticipation', 13 | name='gender', 14 | field=models.CharField(verbose_name='género', blank=True, choices=[('female', 'female'), ('male', 'fale'), ('Otro', 'other')], max_length=32, default=''), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /events/migrations/0004_auto_20180428_1949.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('events', '0003_eventparticipation_gender'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='eventparticipation', 13 | name='gender', 14 | field=models.CharField(verbose_name='género', default='', max_length=32, choices=[('female', 'femenino'), ('male', 'masculino'), ('Otro', 'otro')], blank=True), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /events/migrations/0005_auto_20180429_1546.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import autoslug.fields 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('events', '0004_auto_20180428_1949'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='event', 14 | name='slug', 15 | field=autoslug.fields.AutoSlugField(populate_from='name', editable=True, unique=True, blank=True, null=True, verbose_name='Url'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /events/migrations/0006_alter_event_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-09 16:02 2 | 3 | import autoslug.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('events', '0005_auto_20180429_1546'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='event', 16 | name='slug', 17 | field=autoslug.fields.AutoSlugField(blank=True, editable=True, null=True, populate_from='name', unique=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /events/migrations/0007_alter_eventparticipation_seniority.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2025-01-16 21:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('events', '0006_alter_event_slug'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='eventparticipation', 15 | name='seniority', 16 | field=models.CharField(blank=True, choices=[('Trainee', 'Trainee'), ('Junior', 'Junior'), ('Semi Senior', 'Semi Senior'), ('Senior', 'Senior'), ('Guido', 'Soy Guido Van Rossum')], default='', max_length=100, verbose_name='experiencia'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /events/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/events/migrations/__init__.py -------------------------------------------------------------------------------- /events/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/events/templatetags/__init__.py -------------------------------------------------------------------------------- /events/templatetags/event_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from events.models import Event 4 | 5 | from django.utils import timezone 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.inclusion_tag('events/next_events.html') 11 | def next_events(): 12 | events = Event.objects.filter( 13 | end_at__gte=timezone.now() 14 | ).order_by( 15 | 'start_at' 16 | ) 17 | return {'events': events} 18 | -------------------------------------------------------------------------------- /events/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/events/tests/__init__.py -------------------------------------------------------------------------------- /events/tests/factories.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import datetime, timedelta, utc 2 | from django.contrib.auth import get_user_model 3 | from factory import SubFactory, Faker, PostGenerationMethodCall, Sequence 4 | from factory.django import DjangoModelFactory 5 | 6 | from events.models import Event, EventParticipation 7 | 8 | 9 | User = get_user_model() 10 | DEFAULT_START_TIME = datetime(1956, 1, 31, 0, 0, 0, 0, tzinfo=utc) 11 | DEFAULT_END_TIME = DEFAULT_START_TIME + timedelta(hours=8) 12 | DEFAULT_USER_PASSWORD = 'secret' 13 | 14 | 15 | # This factory could be in any app. 16 | class UserFactory(DjangoModelFactory): 17 | username = Sequence(lambda n: f'user{n}') 18 | password = PostGenerationMethodCall('set_password', DEFAULT_USER_PASSWORD) 19 | email = Faker('email') 20 | 21 | class Meta: 22 | model = User 23 | # Warning!: Using the factory to get an already existing user logs out the user in the 24 | # session :(, maybe because the password setting 25 | django_get_or_create = ('username',) 26 | 27 | 28 | class EventFactory(DjangoModelFactory): 29 | name = Faker('sentence', nb_words=4) 30 | description = Faker('text') 31 | address = Faker('address') 32 | place = Faker('sentence', nb_words=2) 33 | start_at = DEFAULT_START_TIME 34 | end_at = DEFAULT_END_TIME 35 | owner = SubFactory(UserFactory) 36 | 37 | class Meta: 38 | model = Event 39 | 40 | 41 | class FutureEventFactory(EventFactory): 42 | start_at = datetime.now(tz=utc) + timedelta(days=1) 43 | end_at = datetime.now(tz=utc) + timedelta(days=2) 44 | 45 | class Meta: 46 | model = Event 47 | 48 | 49 | class EventParticipationFactory(DjangoModelFactory): 50 | class Meta: 51 | model = EventParticipation 52 | 53 | event = SubFactory(EventFactory) 54 | -------------------------------------------------------------------------------- /events/tests/test_event_participation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the events.EventParticipation Model 3 | 4 | """ 5 | from django.test import TestCase 6 | from events.tests.factories import EventParticipationFactory, UserFactory 7 | 8 | 9 | class IsVerifiedTests(TestCase): 10 | 11 | # Only registered users (can login) are set in the user field. 12 | # Registered users are considered verified by default because we have email 13 | def test_user_is_not_none_then_return_true(self): 14 | participation = EventParticipationFactory(user=UserFactory()) 15 | self.assertTrue(participation.is_verified) 16 | -------------------------------------------------------------------------------- /events/tests/test_view_report_event.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | from django.urls import reverse 3 | from django.test import TestCase, Client 4 | 5 | from events.tests.factories import UserFactory, EventFactory 6 | 7 | 8 | class ReportEventTest(TestCase): 9 | def setUp(self): 10 | self.user = UserFactory() 11 | self.client = Client() 12 | self.client.login(username=self.user.username, password='secret') 13 | self.event = EventFactory() 14 | 15 | def test_report_event_sends_email(self): 16 | """ 17 | Test that reporting an event sends an email to admin and redirects the user. 18 | """ 19 | report_url = reverse('events:report', kwargs={'event_id': self.event.id}) 20 | 21 | # Simulate reporting the event 22 | response = self.client.post(report_url) 23 | 24 | # ✅ Check redirection after reporting 25 | self.assertEqual(response.status_code, 302) 26 | 27 | # ✅ Check that an email was sent 28 | self.assertEqual(len(mail.outbox), 1) 29 | 30 | # ✅ Verify email details 31 | email = mail.outbox[0] 32 | self.assertEqual(email.subject, f'Reporte de evento: {self.event.name}') 33 | self.assertIn(self.event.name, email.body) 34 | self.assertIn(self.event.description, email.body) 35 | self.assertIn('admin@python.org.ar', email.to) 36 | 37 | def test_anonymous_user_cannot_report_event(self): 38 | """ 39 | Ensure that unauthenticated users cannot report an event. 40 | """ 41 | self.client.logout() 42 | report_url = reverse('events:report', kwargs={'event_id': self.event.id}) 43 | response = self.client.post(report_url) 44 | 45 | # 🔒 User should be redirected to login page 46 | self.assertEqual(response.status_code, 302) 47 | self.assertIn('/accounts/login/', response.url) 48 | 49 | # 📧 No email should be sent 50 | self.assertEqual(len(mail.outbox), 0) 51 | -------------------------------------------------------------------------------- /events/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path, path 2 | from django.views.generic.detail import DetailView 3 | 4 | from .models import Event 5 | from .views import (EventDetail, 6 | EventList, 7 | EventCreate, 8 | EventUpdate, 9 | EventDelete, 10 | EventsFeed, 11 | EventParticipationList, 12 | EventParticipationCreate, 13 | EventParticipationDetail, 14 | EventParticipationDelete, 15 | EventParticipationDownload, 16 | ReportEventView) 17 | 18 | urlpatterns = [ 19 | path('', EventList.as_view(), name='events_list_all'), 20 | path('rss', EventsFeed(), name='events_feed'), 21 | path('/', EventDetail.as_view(), name='detail'), 22 | path('add/', EventCreate.as_view(), name='add'), 23 | path('/editar/', EventUpdate.as_view(), name='edit'), 24 | path('/borrar/', EventDelete.as_view(), name='delete'), 25 | re_path(r'^(?P[\w-]+)/$', DetailView.as_view(model=Event), name='event_slug'), 26 | 27 | # Event Registration Management 28 | path('/inscribirse/', EventParticipationCreate.as_view(), name='register'), 29 | path('/inscriptos/', EventParticipationList.as_view(), name='registered'), 30 | path('/inscriptos/csv/', EventParticipationDownload.as_view(), name='registered_csv'), 31 | path( 32 | '/inscripcion//', 33 | EventParticipationDetail.as_view(), 34 | name='registration' 35 | ), 36 | path( 37 | '/inscripcion//borrar/', 38 | EventParticipationDelete.as_view(), 39 | name='unregister' 40 | ), 41 | path('/reportar/', ReportEventView.as_view(), name='report'), 42 | ] 43 | -------------------------------------------------------------------------------- /fixtures/initial_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "password": "pbkdf2_sha256$15000$b7Zz8V6iWtC4$6aYCOytX2INR/4vpdPEYCdBLDPNJe0XUuKbr297V7zk=", 7 | "user_permissions": [], 8 | "first_name": "", 9 | "date_joined": "2016-09-23T05:11:25.013Z", 10 | "is_active": true, 11 | "is_staff": true, 12 | "email": "", 13 | "last_name": "", 14 | "username": "admin", 15 | "last_login": "2016-09-23T05:11:42.256Z", 16 | "groups": [], 17 | "is_superuser": true 18 | } 19 | }, 20 | { 21 | "model": "sites.site", 22 | "pk": 1, 23 | "fields": { 24 | "domain": "localhost:8000", 25 | "name": "PyAr - [Desarrollo]" 26 | } 27 | } 28 | 29 | ] 30 | -------------------------------------------------------------------------------- /fixtures/permission.json: -------------------------------------------------------------------------------- 1 | [{"pk": 1, "model": "auth.group", "fields": {"name": "job_moderator", "permissions": [95, 96, 97]}}] -------------------------------------------------------------------------------- /initialize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python3 manage.py migrate 3 | python3 manage.py createsuperuser 4 | -------------------------------------------------------------------------------- /joboffers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/__init__.py -------------------------------------------------------------------------------- /joboffers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import JobOffer, JobOfferComment 4 | 5 | 6 | admin.site.register(JobOffer) 7 | admin.site.register(JobOfferComment) 8 | -------------------------------------------------------------------------------- /joboffers/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class JoboffersConfig(AppConfig): 5 | name = 'joboffers' 6 | -------------------------------------------------------------------------------- /joboffers/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/management/__init__.py -------------------------------------------------------------------------------- /joboffers/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from joboffers.models import JobOffer 4 | 5 | 6 | class TestPublishCommand(BaseCommand): 7 | def add_arguments(self, parser): 8 | parser.add_argument("--offer-id", type=int, required=False) 9 | 10 | def _handle_publish(self, options, publisher_class): 11 | """Command handler for any publisher.""" 12 | offer_id = options.get('offer_id') 13 | status = None 14 | raw_status = None 15 | publisher = publisher_class() 16 | 17 | if offer_id is not None: 18 | job_offer = JobOffer.objects.get(id=offer_id) 19 | self.stdout.write(self.style.SUCCESS(f'Publicando oferta #{offer_id}.')) 20 | status = publisher.publish(job_offer) 21 | else: 22 | self.stdout.write(self.style.SUCCESS('Publicando una prueba.')) 23 | raw_status = publisher._push_to_api( 24 | 'Esto es una prueba de post.\nhttps://github.com/PyAr/pyarweb', 25 | 'Título de prueba', 26 | 'https://github.com/PyAr/pyarweb' 27 | ) 28 | 29 | if raw_status == 200 or status == publisher.RESULT_OK: 30 | self.stdout.write( 31 | self.style.SUCCESS( 32 | f'Oferta publicada con éxito en: {publisher.name}.' 33 | ) 34 | ) 35 | else: 36 | self.stderr.write( 37 | self.style.ERROR( 38 | f'Hubo un error al querer publicar la oferta en: {publisher.name}.' 39 | ) 40 | ) 41 | -------------------------------------------------------------------------------- /joboffers/management/commands/create_analytics_sample_data.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from joboffers.tests.utils import create_analytics_sample_data 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Creates a sample joboffer with fake visualization data used for testing purposes' 8 | 9 | def handle(self, *args, **options): 10 | create_analytics_sample_data( 11 | test_username='publisher', 12 | test_offer_title='Oferta Prueba (Analítica)' 13 | ) 14 | -------------------------------------------------------------------------------- /joboffers/management/commands/expire_old_offers.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from joboffers.utils import expire_old_offers 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Queries the database for expired offers and send the mails to the publishers' 8 | 9 | def handle(self, *args, **options): 10 | expire_old_offers() 11 | -------------------------------------------------------------------------------- /joboffers/management/commands/notify_pending_moderation_offers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.utils import timezone 5 | from django.utils.translation import gettext as _ 6 | 7 | from joboffers.constants import PENDING_MODERATION_OFFER_DAYS, TELEGRAM_PENDING_MODERATION_MESSAGE 8 | from joboffers.models import JobOffer, OfferState 9 | from joboffers.telegram_api import send_notification_to_moderators 10 | 11 | 12 | def notify_pending_moderation_offers(): 13 | """ 14 | Notify all the moderators that there is a pending joboffer offer moderation 15 | """ 16 | expiration_date = timezone.now() - timedelta(days=PENDING_MODERATION_OFFER_DAYS) 17 | joboffers = JobOffer.objects.filter( 18 | state=OfferState.MODERATION, modified_at__lte=expiration_date 19 | ) 20 | 21 | for joboffer in joboffers: 22 | message = TELEGRAM_PENDING_MODERATION_MESSAGE.format( 23 | offer_url=joboffer.get_full_url(), 24 | moderation_reminder_days=PENDING_MODERATION_OFFER_DAYS 25 | ) 26 | 27 | send_notification_to_moderators(message) 28 | 29 | return len(joboffers) 30 | 31 | 32 | class Command(BaseCommand): 33 | help = ''' 34 | Check for pending moderation offers and send a telegram notification to the moderators group 35 | ''' 36 | 37 | def handle(self, *args, **options): 38 | offers_notifed = notify_pending_moderation_offers() 39 | 40 | self.stdout.write( 41 | self.style.SUCCESS( 42 | _('Se enviaron {offers_notified} recordatorios de moderación.').format( 43 | offers_notified=offers_notifed) 44 | ) 45 | ) 46 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_discourse.py: -------------------------------------------------------------------------------- 1 | from joboffers.management.commands import TestPublishCommand 2 | from joboffers.publishers.discourse import DiscoursePublisher 3 | 4 | 5 | class Command(TestPublishCommand): 6 | help = 'Test sending a post to discourse.' 7 | 8 | def handle(self, *args, **options): 9 | """Post a message to discourse.""" 10 | self._handle_publish(options, DiscoursePublisher) 11 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_facebook.py: -------------------------------------------------------------------------------- 1 | from joboffers.management.commands import TestPublishCommand 2 | from joboffers.publishers.facebook import FacebookPublisher 3 | 4 | 5 | class Command(TestPublishCommand): 6 | help = 'Test sending a post to facebook.' 7 | 8 | def handle(self, *args, **options): 9 | """Post a message to facebook.""" 10 | self._handle_publish(options, FacebookPublisher) 11 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_mastodon.py: -------------------------------------------------------------------------------- 1 | from joboffers.management.commands import TestPublishCommand 2 | from joboffers.publishers.mastodon import MastodonPublisher 3 | 4 | 5 | class Command(TestPublishCommand): 6 | help = 'Test sending a post to Mastodon.' 7 | 8 | def handle(self, *args, **options): 9 | """Post a message to Mastodon.""" 10 | self._handle_publish(options, MastodonPublisher) 11 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_moderation_notification.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from joboffers.telegram_api import send_notification_to_moderators 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Test sending a real notification to telegram.' 8 | 9 | def handle(self, *args, **options): 10 | response = send_notification_to_moderators('trabajito-python') 11 | 12 | assert response.status_code == 200 13 | 14 | self.stdout.write(self.style.SUCCESS('Mensaje de prueba enviado')) 15 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_telegram.py: -------------------------------------------------------------------------------- 1 | from joboffers.management.commands import TestPublishCommand 2 | from joboffers.publishers.telegram import TelegramPublisher 3 | 4 | 5 | class Command(TestPublishCommand): 6 | help = 'Test sending a message to telegram public group.' 7 | 8 | def handle(self, *args, **options): 9 | """Post a message to telegram.""" 10 | self._handle_publish(options, TelegramPublisher) 11 | -------------------------------------------------------------------------------- /joboffers/management/commands/test_twitter.py: -------------------------------------------------------------------------------- 1 | from joboffers.management.commands import TestPublishCommand 2 | from joboffers.publishers.twitter import TwitterPublisher 3 | 4 | 5 | class Command(TestPublishCommand): 6 | help = 'Test sending a post to twitter.' 7 | 8 | def handle(self, *args, **options): 9 | """Post a message to twitter.""" 10 | self._handle_publish(options, TwitterPublisher) 11 | -------------------------------------------------------------------------------- /joboffers/migrations/0002_auto_20211102_1725.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.14 on 2021-11-02 20:25 2 | 3 | from django.db import migrations, models 4 | import taggit_autosuggest.managers 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('taggit', '0003_taggeditem_add_unique_index'), 11 | ('joboffers', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='joboffer', 17 | name='contact_mail', 18 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Correo electrónico'), 19 | ), 20 | migrations.AlterField( 21 | model_name='joboffer', 22 | name='contact_phone', 23 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Teléfono'), 24 | ), 25 | migrations.AlterField( 26 | model_name='joboffer', 27 | name='contact_url', 28 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Sitio web'), 29 | ), 30 | migrations.AlterField( 31 | model_name='joboffer', 32 | name='description', 33 | field=models.TextField(verbose_name='Descripción'), 34 | ), 35 | migrations.AlterField( 36 | model_name='joboffer', 37 | name='tags', 38 | field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Etiquetas'), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /joboffers/migrations/0004_joboffer_short_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-01-03 13:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('joboffers', '0003_auto_20211125_1435'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='joboffer', 15 | name='short_description', 16 | field=models.TextField(default=' ', verbose_name='Descripción corta'), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /joboffers/migrations/0005_alter_joboffer_short_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-01-10 18:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('joboffers', '0004_joboffer_short_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='joboffer', 15 | name='short_description', 16 | field=models.TextField(max_length=200, verbose_name='Descripción corta'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /joboffers/migrations/0006_auto_20220110_1801.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-01-10 21:01 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('pycompanies', '0003_usercompanyprofile'), 11 | ('joboffers', '0005_alter_joboffer_short_description'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='joboffer', 17 | name='company', 18 | field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='pycompanies.company', verbose_name='Empresa'), 19 | preserve_default=False, 20 | ), 21 | migrations.AlterField( 22 | model_name='joboffer', 23 | name='contact_mail', 24 | field=models.EmailField(blank=True, max_length=255, null=True, verbose_name='Correo electrónico'), 25 | ), 26 | migrations.AlterField( 27 | model_name='joboffer', 28 | name='state', 29 | field=models.CharField(choices=[('NEW', 'Nuevo'), ('DEACTIVATED', 'Desactivada'), ('MODERATION', 'En moderación'), ('ACTIVE', 'Activa'), ('REJECTED', 'Rechazada'), ('EXPIRED', 'Caducada')], default='DEACTIVATED', max_length=32, verbose_name='Estado de la oferta'), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /joboffers/migrations/0007_auto_20220216_1022.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-16 13:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('joboffers', '0006_auto_20220110_1801'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='joboffer', 15 | name='contact_url', 16 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='URL Contacto'), 17 | ), 18 | migrations.AlterField( 19 | model_name='joboffer', 20 | name='created_at', 21 | field=models.DateTimeField(auto_now_add=True, verbose_name='Hora de creación'), 22 | ), 23 | migrations.AlterField( 24 | model_name='joboffer', 25 | name='modified_at', 26 | field=models.DateTimeField(auto_now=True, verbose_name='Hora de Modificación'), 27 | ), 28 | migrations.AlterField( 29 | model_name='joboffer', 30 | name='short_description', 31 | field=models.TextField(max_length=512, verbose_name='Descripción corta'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /joboffers/migrations/0008_auto_20220304_1609.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-03-04 19:09 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('easyaudit', '0015_auto_20201019_1217'), 10 | ('joboffers', '0007_auto_20220216_1022'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='JobOfferHistory', 16 | fields=[ 17 | ], 18 | options={ 19 | 'proxy': True, 20 | 'indexes': [], 21 | 'constraints': [], 22 | }, 23 | bases=('easyaudit.crudevent',), 24 | ), 25 | migrations.RemoveField( 26 | model_name='joboffer', 27 | name='fields_hash', 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /joboffers/migrations/0009_alter_joboffer_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-03-09 19:37 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('joboffers', '0008_auto_20220304_1609'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='joboffer', 16 | name='description', 17 | field=models.TextField(validators=[django.core.validators.MinLengthValidator(100)], verbose_name='Descripción'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /joboffers/migrations/0010_auto_20220311_1001.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-03-11 13:01 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('joboffers', '0009_alter_joboffer_description'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='joboffer', 16 | name='description', 17 | field=models.TextField(verbose_name='Descripción'), 18 | ), 19 | migrations.CreateModel( 20 | name='JobOfferVisualization', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('month_and_year', models.PositiveIntegerField()), 25 | ('event_type', models.PositiveSmallIntegerField(choices=[(0, 'Listing View'), (1, 'Detail View'), (2, 'Contact Info View')], verbose_name='Tipo de Evento')), 26 | ('session', models.CharField(max_length=40, verbose_name='Identificador de Sesión')), 27 | ('joboffer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='joboffers.joboffer')), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /joboffers/migrations/0011_rename_joboffervisualization_jobofferaccesslog.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-04-13 13:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('joboffers', '0010_auto_20220311_1001'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel( 14 | old_name='JobOfferVisualization', 15 | new_name='JobOfferAccessLog', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /joboffers/migrations/0012_auto_20220413_1659.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-04-13 19:59 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('joboffers', '0011_rename_joboffervisualization_jobofferaccesslog'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='jobofferaccesslog', 17 | options={'ordering': ['created_at']}, 18 | ), 19 | migrations.AlterField( 20 | model_name='joboffer', 21 | name='title', 22 | field=models.CharField(max_length=255, unique=True, validators=[django.core.validators.MinLengthValidator(20)], verbose_name='Título'), 23 | ), 24 | migrations.AlterField( 25 | model_name='jobofferaccesslog', 26 | name='created_at', 27 | field=models.DateTimeField(default=django.utils.timezone.now), 28 | ), 29 | migrations.AlterField( 30 | model_name='jobofferaccesslog', 31 | name='event_type', 32 | field=models.PositiveSmallIntegerField(choices=[(0, 'Visualización en Listado'), (1, 'Visualización de la oferta completa'), (2, 'Apertura de la información de contacto')], verbose_name='Tipo de Evento'), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /joboffers/migrations/0013_alter_joboffer_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-02 17:58 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('joboffers', '0012_auto_20220413_1659'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='joboffer', 16 | name='title', 17 | field=models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(20)], verbose_name='Título'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /joboffers/migrations/0014_alter_jobofferaccesslog_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-29 22:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('joboffers', '0013_alter_joboffer_title'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='jobofferaccesslog', 15 | options={}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /joboffers/migrations/0015_alter_joboffer_company.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-13 17:25 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('pycompanies', '0005_company_owner_to_usercompanyprofile'), 11 | ('joboffers', '0014_alter_jobofferaccesslog_options'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='joboffer', 17 | name='company', 18 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pycompanies.company', verbose_name='Empresa'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /joboffers/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/migrations/__init__.py -------------------------------------------------------------------------------- /joboffers/publishers/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | 4 | from django.conf import settings 5 | from django.template import Context, Template 6 | from django.utils.module_loading import import_string 7 | 8 | from ..models import JobOffer 9 | 10 | 11 | class Publisher: 12 | """Base class for Publishing classes like Twitter, Facebook, etc.""" 13 | name = None 14 | RESULT_OK = 'ok' 15 | RESULT_BAD = 'bad' 16 | 17 | def _render_offer(self, job_offer: 'JobOffer'): 18 | """Render JobOffer using a custom template for each Publisher.""" 19 | # First, we need to get the file path for the class 20 | # inheriting from the baseclass (not the current __file__) 21 | class_file = inspect.getfile(self.__class__) 22 | path = os.path.dirname(class_file) 23 | template_path = os.path.join(path, 'template.html') 24 | with open(template_path) as template_file: 25 | template_content = template_file.read() 26 | 27 | template = Template(template_content) 28 | context = Context({'job_offer': job_offer}) 29 | return template.render(context) 30 | 31 | def _push_to_api(self, message: str, title: str): 32 | """This method should implement what is necessary to interact with each API.""" 33 | raise NotImplementedError 34 | 35 | def publish(self, job_offer: 'JobOffer'): 36 | """Render and send the JobOffer to the publisher, 37 | using the API configured in push_to_api method.""" 38 | message = self._render_offer(job_offer) 39 | status = self._push_to_api(message, job_offer.title, job_offer.get_full_url()) 40 | 41 | if status in (200, 201): 42 | return self.RESULT_OK 43 | else: 44 | return self.RESULT_BAD 45 | 46 | 47 | def publish_offer(job_offer: 'JobOffer', publishers: list = None): 48 | """Publish a job_offer into the given list of publishers.""" 49 | failed = [] 50 | 51 | if publishers: 52 | for publisher in publishers: 53 | result = publisher().publish(job_offer) 54 | 55 | if result != Publisher.RESULT_OK: 56 | failed.append(publisher.name) 57 | else: 58 | raise ValueError 59 | 60 | return failed 61 | 62 | 63 | def publish_to_all_social_networks(joboffer): 64 | """ 65 | Send the joboffer to all the configured social networks. 66 | """ 67 | publishers = [import_string(p) for p in settings.SOCIAL_NETWORKS_PUBLISHERS] 68 | return publish_offer(joboffer, publishers) 69 | -------------------------------------------------------------------------------- /joboffers/publishers/discourse/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | 4 | import requests 5 | from django.conf import settings 6 | 7 | from joboffers.publishers import Publisher 8 | 9 | MIN_LENGTH_POST_TITLE = 20 10 | 11 | 12 | class DiscoursePublisher(Publisher): 13 | """Discourse Publisher.""" 14 | 15 | name = 'Discourse' 16 | 17 | def _push_to_api(self, message: str, title: str, link: str): 18 | """Publish a message to discourse.""" 19 | headers = { 20 | 'Api-Key': settings.DISCOURSE_API_KEY, 21 | 'Api-Username': settings.DISCOURSE_USERNAME, 22 | } 23 | 24 | uuid_suffix = uuid.uuid4().hex 25 | 26 | # make the title unique by appending part of an uuid; however if the title 27 | # is too short for discourse use the whole uuid 28 | post_title = f'{title} - {uuid_suffix[:8]}' 29 | if len(post_title) < MIN_LENGTH_POST_TITLE: 30 | post_title = f"{title} - {uuid_suffix}" 31 | 32 | payload = { 33 | 'title': post_title, 34 | 'raw': message, 35 | 'category': settings.DISCOURSE_CATEGORY, 36 | } 37 | 38 | url = f'{settings.DISCOURSE_BASE_URL}/posts.json' 39 | try: 40 | resp = requests.post(url, json=payload, headers=headers) 41 | except Exception as err: 42 | logging.error("Unknown error when publishing: %r", err) 43 | status = None 44 | else: 45 | status = resp.status_code 46 | if status != requests.codes.ok: 47 | logging.error( 48 | "Bad server response when publishing: %s (%r); title=%r message=%r", 49 | status, resp.text, post_title, message) 50 | 51 | return status 52 | -------------------------------------------------------------------------------- /joboffers/publishers/discourse/template.html: -------------------------------------------------------------------------------- 1 | Oferta: 2 | {{ job_offer.short_description }} 3 | {{ job_offer.get_full_url }} 4 | -------------------------------------------------------------------------------- /joboffers/publishers/facebook/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from django.conf import settings 5 | 6 | from joboffers.publishers import Publisher 7 | 8 | 9 | FACEBOOK_POST_URL = f'https://graph.facebook.com/{settings.FACEBOOK_PAGE_ID}/feed' 10 | 11 | ERROR_LOG_MESSAGE = 'Falló al querer publicar a facebook, url=%s data=%s: %s' 12 | 13 | 14 | class FacebookPublisher(Publisher): 15 | """Facebook Publisher.""" 16 | name = 'Facebook' 17 | 18 | def _push_to_api(self, message: str, title: str, link: str): 19 | """Publish a message to the configured facebook page.""" 20 | payload = { 21 | 'message': message, 22 | 'link': link, 23 | 'access_token': settings.FACEBOOK_PAGE_ACCESS_TOKEN 24 | } 25 | 26 | try: 27 | result = requests.post(FACEBOOK_POST_URL, data=payload) 28 | except Exception as err: 29 | status = None 30 | result_info = err 31 | else: 32 | status = result.status_code 33 | result_info = result.text 34 | 35 | if status != requests.codes.ok: 36 | logging.error(ERROR_LOG_MESSAGE, FACEBOOK_POST_URL, payload, result_info) 37 | return status 38 | -------------------------------------------------------------------------------- /joboffers/publishers/facebook/template.html: -------------------------------------------------------------------------------- 1 | {{ job_offer.short_description }} 2 | {{ job_offer.get_full_url }} 3 | -------------------------------------------------------------------------------- /joboffers/publishers/mastodon/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from mastodon import Mastodon, errors 5 | 6 | from joboffers.utils import hash_secret 7 | from joboffers.publishers import Publisher 8 | 9 | 10 | def _repr_credentials(): 11 | """Show a string representation of mastodon credentials.""" 12 | # Need to convert to string, in case they are strings or they are not set 13 | credentials_repr = ( 14 | f' MASTODON_AUTH_TOKEN: {hash_secret(settings.MASTODON_AUTH_TOKEN)} ' 15 | f' MASTODON_API_BASE_URL: {hash_secret(settings.MASTODON_API_BASE_URL)} ' 16 | ) 17 | return credentials_repr 18 | 19 | 20 | ERROR_LOG_MESSAGE = ( 21 | 'Falló al querer tootear con las siguientes credenciales (hasheadas): %s - Error: %s' 22 | ) 23 | 24 | 25 | class MastodonPublisher(Publisher): 26 | """Mastodon Publisher.""" 27 | 28 | name = 'Mastodon' 29 | 30 | def _push_to_api(self, message: str, title: str, link: str): 31 | """Publish a message to mastodon.""" 32 | mastodon = Mastodon( 33 | access_token=settings.MASTODON_AUTH_TOKEN, 34 | api_base_url=settings.MASTODON_API_BASE_URL, 35 | ) 36 | 37 | try: 38 | mastodon.status_post(message) 39 | except errors.MastodonUnauthorizedError as err: 40 | status = None 41 | logging.error(ERROR_LOG_MESSAGE, _repr_credentials(), err) 42 | except Exception as err: 43 | status = None 44 | logging.error("Unknown error when tooting: %s", repr(err)) 45 | else: 46 | status = 200 47 | 48 | return status 49 | -------------------------------------------------------------------------------- /joboffers/publishers/mastodon/template.html: -------------------------------------------------------------------------------- 1 | Oferta: 2 | {{ job_offer.short_description }} 3 | {{ job_offer.get_full_url }} 4 | -------------------------------------------------------------------------------- /joboffers/publishers/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from joboffers.publishers import Publisher 4 | from joboffers.telegram_api import send_message 5 | 6 | 7 | class TelegramPublisher(Publisher): 8 | """Telegram Publisher.""" 9 | 10 | name = 'Telegram' 11 | 12 | def _push_to_api(self, message: str, title: str, link: str): 13 | """Publish a message to the configured chat group.""" 14 | chat_id = settings.TELEGRAM_PUBLIC_CHAT_ID 15 | return send_message(message, chat_id) 16 | -------------------------------------------------------------------------------- /joboffers/publishers/telegram/template.html: -------------------------------------------------------------------------------- 1 | Oferta: 2 | {{ job_offer.short_description }} 3 | {{ job_offer.get_full_url }} 4 | -------------------------------------------------------------------------------- /joboffers/publishers/template.html: -------------------------------------------------------------------------------- 1 |

New job!: {{ job_offer.slug }}

2 | -------------------------------------------------------------------------------- /joboffers/publishers/twitter/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tweepy 4 | from django.conf import settings 5 | 6 | from joboffers.utils import hash_secret 7 | from joboffers.publishers import Publisher 8 | 9 | 10 | def _repr_credentials(): 11 | """Show a string representation of twitter credentials.""" 12 | # Need to convert to string, in case they are strings or they are not set 13 | credentials_repr = ( 14 | f' TWITTER_CONSUMER_KEY: {hash_secret(settings.TWITTER_CONSUMER_KEY)} ' 15 | f' TWITTER_CONSUMER_SECRET: {hash_secret(settings.TWITTER_CONSUMER_SECRET)} ' 16 | f' TWITTER_ACCESS_TOKEN: {hash_secret(settings.TWITTER_ACCESS_TOKEN)} ' 17 | f' TWITTER_ACCESS_SECRET: {hash_secret(settings.TWITTER_ACCESS_SECRET)} ' 18 | ) 19 | 20 | return credentials_repr 21 | 22 | 23 | ERROR_LOG_MESSAGE_AUTH = ( 24 | 'Falló al querer autenticar con las siguientes credenciales(hasheadas)' 25 | 'para twitter %s Error: %s' 26 | ) 27 | 28 | ERROR_LOG_MESSAGE_POST = ( 29 | 'Falló al querer twitear con las siguientes credenciales(hasheadas). %s Error: %s' 30 | ) 31 | 32 | ERROR_LOG_MESSAGE_GENERIC = 'Falló al querer publicar a twitter, data=%s: %s' 33 | 34 | 35 | class TwitterPublisher(Publisher): 36 | """Twitter Publisher.""" 37 | 38 | name = 'Twitter' 39 | 40 | def _push_to_api(self, message: str, title: str, link: str): 41 | """Publish a message to twitter.""" 42 | try: 43 | auth = tweepy.OAuthHandler( 44 | settings.TWITTER_CONSUMER_KEY, settings.TWITTER_CONSUMER_SECRET 45 | ) 46 | auth.set_access_token( 47 | settings.TWITTER_ACCESS_TOKEN, settings.TWITTER_ACCESS_SECRET 48 | ) 49 | api = tweepy.API(auth) 50 | except TypeError as err: 51 | logging.error(err) 52 | return 53 | except Exception as err: 54 | logging.error(ERROR_LOG_MESSAGE_AUTH, _repr_credentials(), err) 55 | return 56 | 57 | try: 58 | api.update_status(message) 59 | except tweepy.errors.Unauthorized as err: 60 | # Specifically cacthing this exception as it could be helpful to debug errors on 61 | # this end. 62 | status = 401 63 | logging.error(ERROR_LOG_MESSAGE_POST, _repr_credentials(), err) 64 | except Exception as err: 65 | status = None 66 | logging.error(ERROR_LOG_MESSAGE_POST, _repr_credentials(), err) 67 | else: 68 | status = 200 69 | 70 | return status 71 | -------------------------------------------------------------------------------- /joboffers/publishers/twitter/template.html: -------------------------------------------------------------------------------- 1 | Oferta: 2 | {{ job_offer.short_description }} 3 | {{ job_offer.get_full_url }} 4 | -------------------------------------------------------------------------------- /joboffers/telegram_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse 3 | 4 | import requests 5 | from django.conf import settings 6 | from django.contrib import messages 7 | 8 | from joboffers.constants import TELEGRAM_SENDING_ERROR 9 | from joboffers.utils import hash_secret 10 | 11 | 12 | TELEGRAM_API_URL = 'https://api.telegram.org/bot' 13 | ERROR_LOG_MESSAGE = ( 14 | 'Falló al querer publicar a telegram con las siguientes credenciales(hasheadas). %s Error: %s' 15 | ) 16 | 17 | 18 | def _repr_credentials(): 19 | """Show a string representation of telegram credentials.""" 20 | credentials_repr = ( 21 | f' TELEGRAM_BOT_TOKEN: {hash_secret(settings.TELEGRAM_BOT_TOKEN)} ' 22 | f' TELEGRAM_MODERATORS_CHAT_ID: {settings.TELEGRAM_MODERATORS_CHAT_ID}' 23 | f' TELEGRAM_PUBLIC_CHAT_ID: {settings.TELEGRAM_PUBLIC_CHAT_ID} ' 24 | ) 25 | 26 | return credentials_repr 27 | 28 | 29 | def _compose_message(message: str): 30 | """Santitize, escape and add environment prefix to message.""" 31 | message_with_prefix = " ".join((settings.TELEGRAM_MESSAGE_PREFIX, message)) 32 | safe_message = urllib.parse.quote_plus(message_with_prefix) 33 | return safe_message 34 | 35 | 36 | def _get_request_url(message: str, chat_id: int): 37 | """Compose url for telegram.""" 38 | bot_token = settings.TELEGRAM_BOT_TOKEN 39 | url = f'{TELEGRAM_API_URL}{bot_token}/sendMessage?chat_id={chat_id}&text={message}' 40 | return url 41 | 42 | 43 | def send_message(message: str, chat_id: int, request=None): 44 | """Send a message to a chat using a bot.""" 45 | safe_message = _compose_message(message) 46 | url = _get_request_url(safe_message, chat_id) 47 | status = requests.get(url) 48 | if status.status_code != requests.codes.ok: 49 | logging.error(ERROR_LOG_MESSAGE, _repr_credentials(), status.text) 50 | if request: 51 | messages.add_message(request, TELEGRAM_SENDING_ERROR) 52 | 53 | return status.status_code 54 | 55 | 56 | def send_notification_to_moderators(message: str, request=None): 57 | """Send a notification of a slug thats needs to be moderated to moderator's group.""" 58 | return send_message(message, settings.TELEGRAM_MODERATORS_CHAT_ID) 59 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/_paginator.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 29 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/_tags.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% for tag in object.tags.all %} 3 | {{ tag.name }} 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/history.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/templates/joboffers/history.html -------------------------------------------------------------------------------- /joboffers/templates/joboffers/history/_comment.html: -------------------------------------------------------------------------------- 1 | {% load history %} 2 |
3 |
4 |
{{ comment|verbose_name:"comment_type" }}
5 |
{{ comment.get_comment_type_display }}
6 |
7 |
8 |
{{ comment|verbose_name:"text" }}
9 |
{{ comment.text }}
10 |
11 |
12 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/history/_create.html: -------------------------------------------------------------------------------- 1 | {% load history %} 2 | 3 |
4 | {% for field_item in history_entry.fields.items %} 5 | {% if field_item.0 not in HIDDEN_JOBOFFER_FIELDS %} 6 | {% if field_item.0 == 'description' %} 7 |
8 |
{{ field_item | joboffer_verbose_name }}
9 |
{{ field_item | joboffer_value | safe }}
10 |
11 | {% else %} 12 |
13 |
{{ field_item | joboffer_verbose_name }}
14 |
{{ field_item | joboffer_value | default:"-" }}
15 |
16 | {% endif %} 17 | {% endif %} 18 | {% endfor %} 19 |
20 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/history/_update.html: -------------------------------------------------------------------------------- 1 | {% load history %} 2 | 3 | {% for field_update in history_entry.changes.items %} 4 |
5 | {% with field_item=field_update|get_old_field_item %} 6 |
7 |
{{ field_item | joboffer_verbose_name }}
8 | {% if field_item.0 == 'description' %} 9 |
{{ field_item | joboffer_value | safe }}
10 | {% else %} 11 |
12 | {{ field_item | joboffer_value | default:"-" }} 13 |
14 | {% endif %} 15 |
16 | {% endwith %} 17 | 18 |
19 | 20 |
21 | {% with field_item=field_update|get_new_field_item %} 22 |
23 |
{{ field_item | joboffer_verbose_name }}
24 | {% if field_item.0 == 'description' %} 25 |
{{ field_item | joboffer_value | safe }}
26 | {% else %} 27 |
{{ field_item | joboffer_value | default:"-" }}
28 | {% endif %} 29 |
30 | {% endwith %} 31 |
32 | {% endfor %} 33 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load devtags %} 3 | {% load i18n static %} 4 | {% block title %}Ofertas laborales con Python{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 | 23 | 24 | {% if object_list %} 25 | 38 | 39 | {% include 'joboffers/_paginator.html' with page_obj=page_obj %} 40 | 41 | 42 | {% else %} 43 |
44 |

{% trans 'No se encontraron ofertas.' %}

45 |
46 | {% endif %} 47 | 48 |
49 | 50 | 56 | 57 | 58 |
59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_analytics.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load email_obfuscator %} 4 | {% load devtags %} 5 | 6 | {% block title %} 7 | {{ object.title }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 | 16 | 17 |
18 |
19 |

{% trans 'Resumen de Visualizaciones' %} 20 | 22 | 23 | {% trans 'Descargar como archivo .csv' %} 24 | 25 |

26 | 27 | 28 | {% for concept_name, total in totals %} 29 | 30 | 31 | 32 | 33 | {% endfor %} 34 |
{{ concept_name }}{{ total }}
35 | 36 | {% for title, graph in graphs %} 37 | {% if graph %} 38 |

{{ title }}

39 | {% autoescape off %} 40 | {{ graph }} 41 | {% endautoescape %} 42 | {% endif %} 43 | {% endfor %} 44 |
45 |
46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% load devtags %} 6 | 7 | {% block extra_head %} 8 | 19 | {% endblock %} 20 | 21 | {% block content %} 22 |
23 |
24 | 32 | 33 | {% crispy form %} 34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_history.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load devtags %} 4 | {% load history %} 5 | 6 | {% block title %} 7 | {{ object.title }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 | 13 |
14 | 15 | 18 | 19 |
20 | {% for history_entry in object_list %} 21 |
22 | {% include 'joboffers/history/_header.html' %} 23 |
24 |
25 | {% if history_entry.content_type.model == "joboffer" %} 26 | {% if history_entry.event_type == JobOfferHistory.CREATE %} 27 | {% include 'joboffers/history/_create.html' %} 28 | {% else %} 29 | {% include 'joboffers/history/_update.html' %} 30 | {% endif %} 31 | 32 | {% else %} 33 | {% include 'joboffers/history/_comment.html' with comment=history_entry.joboffer_comment %} 34 | {% endif %} 35 |
36 |
37 |
38 | {% empty %} 39 |

No hay registros históricos para esta oferta.

40 | {% endfor %} 41 |
42 | {% include 'joboffers/_paginator.html' with page_obj=page_obj %} 43 |
44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load devtags i18n static %} 3 | {% block title %}Ofertas laborales con Python{% endblock %} 4 | 5 | {% block left-column %} 6 | 7 | 8 |
9 |
10 | 17 | {% if object_list %} 18 | {% for object in page_obj %} 19 | 29 | {% endfor %} 30 | 31 | {% include 'joboffers/_paginator.html' with page_obj=page_obj %} 32 | 33 | {% else %} 34 |
35 |

{% trans 'La búsqueda no produjo resultados.' %}

36 |
37 | {% endif %} 38 |
39 | 40 | RSS 41 | 42 |
43 |
44 |
45 | {% endblock %} 46 | 47 | {% block right-column %} 48 | {% if user.is_authenticated %} 49 | {% include "companies/_user_actions.html" %} 50 | {% endif %} 51 | 52 | {% include "_tags_filtering_form.html" %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_overview.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 |
3 |
Estado
4 |
5 |

6 | 9 |

10 |
11 |
Última Modificación
12 |
13 | {{ obj.modified_at|date:"SHORT_DATETIME_FORMAT" }} 14 |
15 | {% if obj.location %} 16 |
Lugar de trabajo
17 |
{{ obj.location|title }}
18 | {% endif %} 19 | 20 | {% if obj.company %} 21 |
Empresa
22 |
23 | {{ obj.company.name }} 24 |
25 | {% endif %} 26 |
Permite trabajar remoto
27 |
{% if obj.allows_remote_work %}Sí{% else %}No{% endif %}
28 | 29 |
Experiencia Requerida
30 |
{{ obj.get_experience_display }}
31 | 32 |
Modalidad de Trabajo
33 |
{{ obj.get_remoteness_display }}
34 | 35 |
Tipo de Contratación
36 |
{{ obj.get_hiring_type_display }}
37 | 38 |
Rango Salarial
39 |
{{ obj.salary }}
40 | 41 | 42 | 43 |
44 | 45 | 48 |
49 | 69 | 70 |
71 | 72 | -------------------------------------------------------------------------------- /joboffers/templates/joboffers/joboffer_reject.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |
7 |
8 | 12 | {% crispy form %} 13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /joboffers/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/templatetags/__init__.py -------------------------------------------------------------------------------- /joboffers/templatetags/history.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.core.exceptions import FieldDoesNotExist 3 | 4 | from ..models import JobOffer 5 | 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter 11 | def verbose_name(model, field_name): 12 | """ 13 | Get the verbose name for a given model instance and field name. 14 | The model must have the get_options() method implemented 15 | """ 16 | meta = model.get_options() 17 | 18 | field = meta.get_field(field_name) 19 | return field.verbose_name 20 | 21 | 22 | @register.filter 23 | def joboffer_verbose_name(field_item): 24 | """ 25 | Get the verbose name for a field_item (key-value). It returns the same field_name if the field 26 | is not present in the model anymore. That will be the case of a field removed/renamed in the 27 | model. 28 | The model must have the get_options() method implemented 29 | """ 30 | field_name, _ = field_item 31 | meta = JobOffer.get_options() 32 | try: 33 | field = meta.get_field(field_name) 34 | except FieldDoesNotExist: 35 | return field_name 36 | return field.verbose_name 37 | 38 | 39 | @register.filter 40 | def joboffer_value(field_item): 41 | """ 42 | Get the human readable value for a joboffer for a key-value pair. 43 | """ 44 | field_name, field_value = field_item 45 | meta = JobOffer.get_options() 46 | 47 | try: 48 | field = meta.get_field(field_name) 49 | except FieldDoesNotExist: 50 | return field_value 51 | 52 | attr_name = field.attname # Foreign keys use a different attribute name 53 | joboffer = JobOffer(**{attr_name: field_value}) 54 | 55 | display_function = getattr(joboffer, f"get_{field_name}_display", None) 56 | 57 | if display_function: 58 | return display_function() 59 | else: 60 | try: 61 | return getattr(joboffer, field_name) 62 | except ValueError: 63 | return field_value 64 | 65 | 66 | @register.filter 67 | def get_old_field_item(field_update): 68 | """ 69 | Get the old key-value for a field_update. 70 | """ 71 | return (field_update[0], field_update[1][0]) 72 | 73 | 74 | @register.filter 75 | def get_new_field_item(field_update): 76 | """ 77 | Get the new key-value for a field_update. 78 | """ 79 | return (field_update[0], field_update[1][1]) 80 | -------------------------------------------------------------------------------- /joboffers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/joboffers/tests/__init__.py -------------------------------------------------------------------------------- /joboffers/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | 4 | from requests_mock import ANY 5 | 6 | from events.tests.factories import UserFactory 7 | 8 | 9 | @pytest.fixture(name='publisher_client') 10 | def create_publisher_client(client, user_company_profile): 11 | """ 12 | Django client fixture with a logged publisher user 13 | """ 14 | user = user_company_profile.user 15 | client.force_login(user) 16 | 17 | return client 18 | 19 | 20 | @pytest.fixture(name='admin_user') 21 | def create_admin_user(): 22 | """ 23 | Create and return a random admin user 24 | """ 25 | return UserFactory(is_superuser=True) 26 | 27 | 28 | @pytest.fixture(name='admin_client') 29 | def create_admin_client(client, admin_user): 30 | """ 31 | Django client fixture with a logged admin user 32 | """ 33 | client.force_login(admin_user) 34 | 35 | return client 36 | 37 | 38 | class DummyTelegram: 39 | """ 40 | Convenience wrapper of requests mock to simulate telegram responses and tests messages sent 41 | """ 42 | def __init__(self, requests_mock): 43 | self.requests_mock = requests_mock 44 | 45 | @property 46 | def call_history(self): 47 | return [history.qs for history in self.requests_mock.request_history] 48 | 49 | 50 | @pytest.fixture(name='telegram_dummy') 51 | def create_telegram_dummy(requests_mock, settings): 52 | settings.TELEGRAM_BOT_TOKEN = '12345' 53 | settings.TELEGRAM_MESSAGE_PREFIX = '[TEST]' 54 | settings.TELEGRAM_MODERATORS_CHAT_ID = 1 55 | 56 | matcher = re.compile(r'api.telegram.org/bot.*$') 57 | requests_mock.register_uri(ANY, matcher, text='dummy response') 58 | 59 | return DummyTelegram(requests_mock) 60 | -------------------------------------------------------------------------------- /joboffers/tests/joboffers_descriptions.py: -------------------------------------------------------------------------------- 1 | LONG_JOBOFFER_DESCRIPTION = ( 2 | '

Onapsis is rapidly expanding, achieving record growth year after year. ' 3 | 'We are seeking passionate contributors who thrive in an open and collaborative environment.' 4 | '

Key activities and responsibilities:

Understand how to' 5 | ' implement an assigned requirement, estimating its effort, proposing different solutions.
' 6 | 'Evaluate new technologies related to the core business of the company.
Create back-end ' 7 | 'automated test cases.
Propose improvements to current development procedures.
Clearly' 8 | ' document the iteration between modules and their APIs
Work collaboratively within a team' 9 | ' of designers and engineers to iterate towards product definition. Be ready to speak your ' 10 | 'mind, but also commit to team direction.

' 11 | ) 12 | 13 | STRIPPED_LONG_JOBOFFER_DESCRIPTION = ( 14 | 'Onapsis is rapidly expanding, achieving record growth year after year. We are seeking' 15 | ' passionate contributors who thrive in an open and collaborative environment. Key activities' 16 | ' and responsibilities: Understand how to implement an assigned requirement, estimating its ' 17 | 'effort, proposing different solutions. Evaluate new technologies related to the core business' 18 | ' of the company. Create back-end automated test cases. Propose improvements to current ' 19 | 'development procedures. Clearly document the iteration between m' 20 | ) 21 | 22 | SHORT_JOBOFFER_DESCRIPTION = '

Job offer title

Job offer description

' 23 | 24 | STRIPPED_SHORT_JOBOFFER_DESCRIPTION = 'Job offer title Job offer description' 25 | -------------------------------------------------------------------------------- /joboffers/tests/test_expire_old_offers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from datetime import timedelta 4 | 5 | from django.core import mail 6 | from django.utils import timezone 7 | 8 | from pycompanies.tests.factories import UserCompanyProfileFactory 9 | 10 | from .factories import JobOfferFactory 11 | from ..constants import EXPIRED_OFFER_MAIL_SUBJECT 12 | from ..models import JobOffer, OfferState 13 | from ..management.commands.expire_old_offers import expire_old_offers 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_expire_old_offers(): 18 | """ 19 | Test expiration of old joboffers command 20 | """ 21 | profile = UserCompanyProfileFactory.create() 22 | company = profile.company 23 | 24 | today = timezone.now() 25 | two_hundred_days_ago = today - timedelta(days=200) 26 | JobOfferFactory.create(company=company) 27 | offer2 = JobOfferFactory.create(company=company, state=OfferState.ACTIVE) 28 | JobOfferFactory.create(company=company, state=OfferState.ACTIVE) 29 | JobOfferFactory.create(company=company, state=OfferState.ACTIVE) 30 | 31 | JobOffer.objects.filter(id=offer2.id).update(modified_at=two_hundred_days_ago) 32 | 33 | expire_old_offers() 34 | 35 | assert len(mail.outbox) == 1 36 | assert mail.outbox[0].subject == EXPIRED_OFFER_MAIL_SUBJECT.format(title=offer2.title) 37 | assert JobOffer.objects.filter(state=OfferState.EXPIRED).count() == 1 38 | -------------------------------------------------------------------------------- /joboffers/tests/test_mastodon_publisher.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import mastodon 4 | 5 | from ..publishers.mastodon import MastodonPublisher 6 | 7 | DUMMY_MESSAGE = 'message' 8 | DUMMY_TITLE = 'title' 9 | DUMMY_LINK = 'https://example.com' 10 | 11 | 12 | class DummyAPIBad: 13 | def __init__(self, to_raise): 14 | self.to_raise = to_raise 15 | 16 | def status_post(self, *args, **kwargs): 17 | raise self.to_raise 18 | 19 | 20 | class DummyAPIOK: 21 | def status_post(*args, **kwargs): 22 | return 23 | 24 | 25 | @patch('joboffers.publishers.mastodon.Mastodon') 26 | def test_push_to_api_bad_credentials(mock_api, settings, caplog): 27 | """Test exception when the credentials are wrong.""" 28 | mock_api.return_value = DummyAPIBad(mastodon.errors.MastodonUnauthorizedError("bad auth")) 29 | settings.MASTODON_AUTH_TOKEN = "wrong" 30 | settings.MASTODON_API_BASE_URL = "creds" 31 | 32 | status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) 33 | assert status is None 34 | 35 | expected_error_message = "Falló al querer tootear con las siguientes credenciales (hasheadas)" 36 | assert expected_error_message in caplog.text 37 | 38 | 39 | @patch('joboffers.publishers.mastodon.Mastodon') 40 | def test_push_to_api_generic_error(mock_api, settings, caplog): 41 | """Something went wrong.""" 42 | mock_api.return_value = DummyAPIBad(ValueError("boom")) 43 | settings.MASTODON_AUTH_TOKEN = "good" 44 | settings.MASTODON_API_BASE_URL = "creds" 45 | 46 | status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) 47 | assert status is None 48 | 49 | expected_error_message = "Unknown error when tooting: ValueError" 50 | assert expected_error_message in caplog.text 51 | 52 | 53 | @patch('joboffers.publishers.mastodon.Mastodon') 54 | def test_push_to_api_ok(mock_api, settings): 55 | mock_api.return_value = DummyAPIOK 56 | settings.MASTODON_AUTH_TOKEN = "good" 57 | settings.MASTODON_API_BASE_URL = "creds" 58 | 59 | status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) 60 | assert status == 200 61 | -------------------------------------------------------------------------------- /joboffers/tests/test_notify-pending_moderation_offers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from datetime import timedelta 4 | from unittest.mock import patch 5 | 6 | from django.utils import timezone 7 | 8 | from joboffers.constants import TELEGRAM_PENDING_MODERATION_MESSAGE 9 | from joboffers.models import JobOffer, OfferState 10 | from joboffers.management.commands.notify_pending_moderation_offers import ( 11 | notify_pending_moderation_offers 12 | ) 13 | from .factories import JobOfferFactory 14 | from .fixtures import create_telegram_dummy # noqa 15 | 16 | 17 | @pytest.mark.django_db 18 | @patch( 19 | "joboffers.management.commands.notify_pending_moderation_offers.PENDING_MODERATION_OFFER_DAYS", 20 | 2) 21 | def test_remind_offers_in_moderation(telegram_dummy): 22 | """Expiration of old joboffers command.""" 23 | today = timezone.now() 24 | two_hundred_days_ago = today - timedelta(days=200) 25 | JobOfferFactory.create() 26 | offer2 = JobOfferFactory.create(state=OfferState.MODERATION) 27 | JobOfferFactory.create(state=OfferState.MODERATION) 28 | JobOfferFactory.create(state=OfferState.ACTIVE) 29 | 30 | JobOffer.objects.filter(id=offer2.id).update(modified_at=two_hundred_days_ago) 31 | 32 | offers_notified = notify_pending_moderation_offers() 33 | 34 | telegram_history = telegram_dummy.call_history 35 | sent_message = telegram_history[0]['text'][0] 36 | 37 | assert offers_notified == 1 38 | assert len(telegram_history) == 1 39 | assert sent_message.endswith(TELEGRAM_PENDING_MODERATION_MESSAGE.format( 40 | offer_url=offer2.get_full_url(), 41 | moderation_reminder_days=2 42 | )) 43 | -------------------------------------------------------------------------------- /joboffers/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.urls import re_path 3 | from .views import ( 4 | DownloadAnalyticsAsCsv, JobOfferAdminView, JobOfferAnalyticsView, JobOfferApproveView, 5 | JobOfferCreateView, JobOfferDeactivateView, JobOfferDetailView, JobOfferHistoryView, 6 | JobOfferListView, JobOfferUpdateView, JobOfferRejectView, JobOfferReactivateView, 7 | JobOfferRequestModerationView, TrackContactInfoView, JobOffersFeed 8 | ) 9 | 10 | 11 | urlpatterns = [ 12 | path('', JobOfferListView.as_view(), name='list'), 13 | path('admin/', JobOfferAdminView.as_view(), name='admin'), 14 | path('nueva/', JobOfferCreateView.as_view(), name='add'), 15 | path('rss/', JobOffersFeed(), name='feed'), 16 | re_path(r'^(?P[\w-]+)/rechazar/$', JobOfferRejectView.as_view(), name='reject'), 17 | re_path(r'^(?P[\w-]+)/aceptar/$', JobOfferApproveView.as_view(), name='approve'), 18 | re_path( 19 | r'^(?P[\w-]+)/desactivar/$', JobOfferDeactivateView.as_view(), 20 | name='deactivate' 21 | ), 22 | re_path( 23 | r'^(?P[\w-]+)/reactivar/$', JobOfferReactivateView.as_view(), 24 | name='reactivate' 25 | ), 26 | re_path( 27 | r'^(?P[\w-]+)/solicitar-moderacion/$', JobOfferRequestModerationView.as_view(), 28 | name='request_moderation' 29 | ), 30 | re_path( 31 | r'^(?P[\w-]+)/historial/$', JobOfferHistoryView.as_view(), 32 | name='history' 33 | ), 34 | re_path(r'^(?P[\w-]+)/$', JobOfferDetailView.as_view(), name='view'), 35 | re_path( 36 | r'^(?P[\w-]+)/editar$', JobOfferUpdateView.as_view(), 37 | name='edit' 38 | ), 39 | re_path( 40 | r'^(?P[\w-]+)/track-contact-info-view$', TrackContactInfoView.as_view(), 41 | name='track-contact-info-view' 42 | ), 43 | re_path( 44 | r'^(?P[\w-]+)/analitica$', JobOfferAnalyticsView.as_view(), 45 | name='analytics' 46 | ), 47 | re_path( 48 | r'^(?P[\w-]+)/visitas.csv$', DownloadAnalyticsAsCsv.as_view(), 49 | name='download-analytics-csv' 50 | ) 51 | ] 52 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pyarweb.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /news/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/news/__init__.py -------------------------------------------------------------------------------- /news/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from django_summernote.widgets import SummernoteWidget 4 | 5 | from .models import NewsArticle 6 | 7 | 8 | class NewsAdminForm(forms.ModelForm): 9 | class Meta: 10 | model = NewsArticle 11 | fields = ('title', 'introduction', 'owner', ) 12 | widgets = {'body': SummernoteWidget()} 13 | 14 | 15 | @admin.register(NewsArticle) 16 | class NewsAdmin(admin.ModelAdmin): 17 | form = NewsAdminForm 18 | -------------------------------------------------------------------------------- /news/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django_summernote.widgets import SummernoteInplaceWidget 3 | from django.utils.translation import gettext_lazy as _ 4 | from crispy_forms.helper import FormHelper 5 | from crispy_forms.layout import Submit, Reset 6 | from .models import NewsArticle 7 | 8 | 9 | class NewsArticleForm(forms.ModelForm): 10 | """A PyAr news article form.""" 11 | 12 | body = forms.CharField(widget=SummernoteInplaceWidget()) 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self.helper = FormHelper() 17 | self.helper.add_input(Submit('news_submit', _('Guardar'))) 18 | self.helper.add_input(Reset('news_reset', _('Limpiar'), 19 | css_class='btn-default')) 20 | 21 | class Meta: 22 | model = NewsArticle 23 | exclude = ('owner',) 24 | -------------------------------------------------------------------------------- /news/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import taggit_autosuggest.managers 3 | import django.utils.timezone 4 | import model_utils.fields 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('taggit', '0002_auto_20150616_2121'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='NewsArticle', 18 | fields=[ 19 | ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), 20 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 21 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 22 | ('title', models.CharField(max_length=255, verbose_name='Título')), 23 | ('introduction', models.TextField(null=True, verbose_name='Introducción', blank=True)), 24 | ('body', models.TextField(verbose_name='Contenido')), 25 | ('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 26 | ('tags', taggit_autosuggest.managers.TaggableManager(through='taggit.TaggedItem', help_text='A comma-separated list of tags.', blank=True, to='taggit.Tag', verbose_name='Etiquetas')), 27 | ], 28 | options={ 29 | 'ordering': ('-created',), 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /news/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/news/migrations/__init__.py -------------------------------------------------------------------------------- /news/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from django.urls import reverse 4 | from django.utils.translation import gettext as _ 5 | from taggit_autosuggest.managers import TaggableManager 6 | from model_utils.models import TimeStampedModel 7 | 8 | 9 | class NewsArticle(TimeStampedModel): 10 | 11 | """A PyAr news article.""" 12 | 13 | title = models.CharField(max_length=255, verbose_name=_('Título')) 14 | introduction = models.TextField(null=True, blank=True, 15 | verbose_name=_('Introducción')) 16 | body = models.TextField(verbose_name=_('Contenido')) 17 | owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 18 | tags = TaggableManager(verbose_name=_('Etiquetas'), blank=True) 19 | 20 | def get_absolute_url(self): 21 | return reverse('news_view', args=(self.id,)) 22 | 23 | def __unicode__(self): 24 | return f'{self.title}' 25 | 26 | class Meta: 27 | ordering = ('-created',) 28 | -------------------------------------------------------------------------------- /news/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/news/tests/__init__.py -------------------------------------------------------------------------------- /news/tests/factories.py: -------------------------------------------------------------------------------- 1 | from factory import SubFactory, Sequence, post_generation 2 | from factory.django import DjangoModelFactory 3 | 4 | from events.tests.factories import UserFactory 5 | from news.models import NewsArticle 6 | 7 | 8 | class NewsArticleFactory(DjangoModelFactory): 9 | class Meta: 10 | model = NewsArticle 11 | 12 | owner = SubFactory(UserFactory) 13 | title = Sequence(lambda n: 'news_title_%i' % n) 14 | 15 | @post_generation 16 | def set_created(obj, create, extracted, **kwargs): 17 | """ 18 | Update the creation time of the built instance. As it is an auto-generated field, we must 19 | set its value after creation. 20 | 21 | To use: NewsArticleFactory(set_created='1985-10-26 09:00Z') 22 | 23 | """ 24 | if extracted: 25 | obj.created = extracted 26 | obj.save() 27 | -------------------------------------------------------------------------------- /news/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.views.generic.detail import DetailView 3 | from django.urls import path 4 | 5 | from .models import NewsArticle 6 | from .views import ( 7 | NewsArticleCreate, 8 | NewsArticleDelete, 9 | NewsArticleList, 10 | NewsArticleUpdate, 11 | NewsFeed 12 | ) 13 | 14 | urlpatterns = [ 15 | path('', NewsArticleList.as_view(), name='news_list_all'), 16 | path('rss', NewsFeed(), name='news_feed'), 17 | path('add/', login_required(NewsArticleCreate.as_view()), name='news_add'), 18 | path('/', DetailView.as_view(model=NewsArticle), name='news_view'), 19 | path('/delete/', login_required(NewsArticleDelete.as_view()), name='news_delete'), 20 | path('/update/', login_required(NewsArticleUpdate.as_view()), name='news_update'), 21 | ] 22 | -------------------------------------------------------------------------------- /news/views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse_lazy 2 | from django.views.generic.edit import UpdateView, CreateView, DeleteView 3 | from django.views.generic import ListView 4 | from django.utils.translation import gettext as _ 5 | from community.views import OwnedObject, FilterableList 6 | from .models import NewsArticle 7 | from .forms import NewsArticleForm 8 | from django.contrib.syndication.views import Feed 9 | 10 | 11 | class NewsFeed(Feed): 12 | title = "Feed de Noticias de Pyar" 13 | link = reverse_lazy("news_list_all") 14 | description = "Novedades e información de Python Argentina" 15 | 16 | def items(self): 17 | return NewsArticle.objects.order_by('-created')[0:10] 18 | 19 | def item_title(self, item): 20 | return item.title 21 | 22 | def item_description(self, item): 23 | return item.body 24 | 25 | def item_pubdate(self, item): 26 | return item.created 27 | 28 | def categories(self, item): 29 | if item: 30 | return item.tags.values_list('name', flat=True) 31 | return () 32 | 33 | 34 | class NewsArticleCreate(CreateView): 35 | model = NewsArticle 36 | form_class = NewsArticleForm 37 | 38 | def form_valid(self, form): 39 | form.instance.owner = self.request.user 40 | return super().form_valid(form) 41 | 42 | 43 | class NewsArticleDelete(DeleteView, OwnedObject): 44 | """Delete a News.""" 45 | model = NewsArticle 46 | success_url = reverse_lazy('news_list_all') 47 | 48 | 49 | class NewsArticleUpdate(UpdateView, OwnedObject): 50 | """Updates a NewsArticle.""" 51 | model = NewsArticle 52 | form_class = NewsArticleForm 53 | 54 | def get_context_data(self, **kwargs): 55 | context = super().get_context_data(**kwargs) 56 | context['page_title'] = _('Editar noticia') 57 | return context 58 | 59 | 60 | class NewsArticleList(ListView, FilterableList): 61 | model = NewsArticle 62 | paginate_by = 10 63 | -------------------------------------------------------------------------------- /prod_requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | gunicorn==20.1.0 3 | raven==6.10.0 4 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Cambios propuestos: 4 | 5 | - 6 | - 7 | 8 | ## Temas tratados: 9 | 10 | - Closes 11 | 12 | ## Pruebas realizadas: 13 | 14 | 15 | - 16 | - 17 | 18 | 19 | ## Cómo probar los cambios: 20 | 21 | 22 | 1. 23 | 2. 24 | 3. 25 | 26 | Gracias por contribuir con el proyecto. 27 | 28 | Estamos pendiente de revisar los cambios propuestos. 29 | -------------------------------------------------------------------------------- /pyarweb/__init__.py: -------------------------------------------------------------------------------- 1 | # from .celery import app as celery_app 2 | -------------------------------------------------------------------------------- /pyarweb/celery.py: -------------------------------------------------------------------------------- 1 | """Configure Celery for PyAr Web.""" 2 | 3 | 4 | import os 5 | 6 | from celery import Celery 7 | 8 | from django.conf import settings 9 | 10 | 11 | # set the default Django settings module for the 'celery' program. 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyarweb.settings') 13 | 14 | app = Celery('pyarweb') 15 | 16 | # Using a string here means the worker will not have to 17 | # pickle the object when using Windows. 18 | app.config_from_object(settings) 19 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 20 | -------------------------------------------------------------------------------- /pyarweb/formats/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pyarweb/formats/__init__.py -------------------------------------------------------------------------------- /pyarweb/formats/es_AR/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pyarweb/formats/es_AR/__init__.py -------------------------------------------------------------------------------- /pyarweb/formats/es_AR/formats.py: -------------------------------------------------------------------------------- 1 | # The *_FORMAT strings use the Django date format syntax, 2 | # see http://docs.djangoproject.com/en/dev/ref/templates/builtins/#date 3 | DATE_FORMAT = r'j \d\e N \d\e Y' 4 | TIME_FORMAT = r'H:i' 5 | DATETIME_FORMAT = r'j \d\e N \d\e Y H:i' 6 | YEAR_MONTH_FORMAT = r'F Y' 7 | MONTH_DAY_FORMAT = r'j \d\e F' 8 | SHORT_DATE_FORMAT = r'd/m/Y' 9 | SHORT_DATETIME_FORMAT = r'd/m/Y H:i' 10 | FIRST_DAY_OF_WEEK = 0 # 0: Sunday, 1: Monday 11 | 12 | # The *_INPUT_FORMATS strings use the Python strftime format syntax, 13 | # see http://docs.python.org/library/datetime.html#strftime-strptime-behavior 14 | DATE_INPUT_FORMATS = ( 15 | '%d/%m/%Y', # '31/12/2009' 16 | '%d/%m/%y', # '31/12/09' 17 | ) 18 | DATETIME_INPUT_FORMATS = ( 19 | '%d/%m/%Y %H:%M:%S', 20 | '%d/%m/%Y %H:%M:%S.%f', 21 | '%d/%m/%Y %H:%M', 22 | '%d/%m/%y %H:%M:%S', 23 | '%d/%m/%y %H:%M:%S.%f', 24 | '%d/%m/%y %H:%M', 25 | ) 26 | DECIMAL_SEPARATOR = ',' 27 | THOUSAND_SEPARATOR = '.' 28 | NUMBER_GROUPING = 3 29 | -------------------------------------------------------------------------------- /pyarweb/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.forms import SignupForm 2 | from captcha.fields import CaptchaField, CaptchaTextInput 3 | 4 | 5 | class CustomCaptchaTextInput(CaptchaTextInput): 6 | # HACK - No entiendo por qué esto apunta a community/templates y no al /templates en el root 7 | template_name = "account/custom_captcha.html" 8 | 9 | def image_url(self): 10 | # Agrego el "@2" para enviar un captcha con más resolucioón 11 | # https://django-simple-captcha.readthedocs.io/en/latest/advanced.html#captcha-2x-image 12 | return super().image_url().removesuffix("/") + "@2" 13 | 14 | 15 | class SignupFormWithCaptcha(SignupForm): 16 | captcha = CaptchaField(widget=CustomCaptchaTextInput) 17 | -------------------------------------------------------------------------------- /pyarweb/gunicorn_cfg.py: -------------------------------------------------------------------------------- 1 | bind = '0.0.0.0:8000' 2 | # Assuming one CPU. http://docs.gunicorn.org/en/latest/settings.html#workers 3 | workers = 3 4 | # You can send signals to it http://docs.gunicorn.org/en/latest/signals.html 5 | pidfile = '/run/gunicorn.pid' 6 | # Logformat including request time. 7 | access_log_format = '%(h)s %({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" "%(L)s seconds"' # noqa 8 | loglevel = 'info' 9 | accesslog = '-' # stdout 10 | errorlog = '-' # stdout 11 | timeout = 84000 12 | -------------------------------------------------------------------------------- /pyarweb/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pyarweb/settings/__init__.py -------------------------------------------------------------------------------- /pyarweb/settings/development/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pyarweb.settings.base import * # NOQA 4 | 5 | # Instead of sending out real emails the console backend just writes 6 | # the emails that would be sent to the standard output. 7 | # By default, the console backend writes to stdout 8 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 9 | 10 | 11 | DEBUG = True 12 | 13 | # Prefix for telegram messages 14 | TELEGRAM_MESSAGE_PREFIX = '[DEV]' 15 | 16 | SOCIAL_NETWORKS_PUBLISHERS = [] # Empty by default to avoid sending on local testing 17 | 18 | # BASE_URL to use in any notification that might require them 19 | BASE_URL = os.environ.get('BASE_URL', 'http://localhost:8000') 20 | -------------------------------------------------------------------------------- /pyarweb/settings/production/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pyarweb.settings.base import * # NOQA 5 | 6 | DEBUG = False 7 | TEMPLATE_DEBUG = False 8 | 9 | CACHES = { 10 | 'default': { 11 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 12 | 'LOCATION': 'pyarweb_cache', 13 | } 14 | } 15 | 16 | ALLOWED_HOSTS = ['*'] 17 | 18 | ACCOUNT_EMAIL_SUBJECT_PREFIX = "[PyAr]" 19 | 20 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 21 | EMAIL_HOST = os.environ.get('EMAIL_HOST') 22 | EMAIL_PORT = os.environ.get('EMAIL_PORT', '587') 23 | EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') 24 | EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') 25 | EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', True) 26 | EMAIL_TIMEOUT = int(os.environ.get('EMAIL_TIMEOUT', '10')) 27 | EMAIL_FROM = os.environ.get('EMAIL_FROM', 'do_not_reply@python.org.ar') 28 | 29 | PYAR_WIKI_URL = 'https://wiki.python.org.ar/' 30 | 31 | 32 | SENDFILE_BACKEND = "sendfile.backends.nginx" 33 | SENDFILE_ROOT = '/home/www-pyar/pyarweb/pyarweb/media/waliki_attachments/' 34 | SENDFILE_URL = '/private' 35 | 36 | DEFAULT_FILE_STORAGE = "storages.backends.azure_storage.AzureStorage" 37 | STATICFILES_STORAGE = "storages.backends.azure_storage.AzureStorage" 38 | 39 | # Raven 40 | # True if we are running ./manage.py test 41 | TEST_RUNNING = [x for x in sys.argv if 'test' in x] 42 | 43 | if not TEST_RUNNING: 44 | # Este es un workaround para evitar que unitests.discover importe el modulo y ejecute 45 | # raven. 46 | # Mejores maneras de evitar esto bienvenidas. 47 | import raven 48 | INSTALLED_APPS += ('raven.contrib.django.raven_compat',) # NOQA 49 | RAVEN_CONFIG = { 50 | 'dsn': os.environ.get("SENTRY_DSN", "NOT_CONFIGURED"), 51 | 'release': raven.fetch_git_sha(BASE_DIR), # NOQA 52 | } 53 | 54 | # Prefix for telegram messages 55 | TELEGRAM_MESSAGE_PREFIX = '' 56 | -------------------------------------------------------------------------------- /pyarweb/settings/staging/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pyarweb.settings.base import * # NOQA 5 | 6 | DEBUG = False 7 | TEMPLATE_DEBUG = False 8 | 9 | CACHES = { 10 | 'default': { 11 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 12 | 'LOCATION': 'pyarweb_cache', 13 | } 14 | } 15 | 16 | ALLOWED_HOSTS = ['*'] 17 | 18 | ACCOUNT_EMAIL_SUBJECT_PREFIX = "[PyAr]" 19 | 20 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 21 | EMAIL_HOST = os.environ.get('EMAIL_HOST') 22 | EMAIL_PORT = os.environ.get('EMAIL_PORT', '587') 23 | EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') 24 | EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') 25 | EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', True) 26 | EMAIL_TIMEOUT = int(os.environ.get('EMAIL_TIMEOUT', '10')) 27 | EMAIL_FROM = os.environ.get('EMAIL_FROM', 'do_not_reply@python.org.ar') 28 | 29 | PYAR_WIKI_URL = 'https://wiki.staging.python.org.ar' 30 | 31 | SENDFILE_BACKEND = "sendfile.backends.nginx" 32 | SENDFILE_ROOT = '/home/www-pyar/pyarweb/pyarweb/media/waliki_attachments/' 33 | SENDFILE_URL = '/private' 34 | 35 | DEFAULT_FILE_STORAGE = "storages.backends.azure_storage.AzureStorage" 36 | STATICFILES_STORAGE = "storages.backends.azure_storage.AzureStorage" 37 | 38 | # Raven 39 | # True if we are running ./manage.py test 40 | TEST_RUNNING = [x for x in sys.argv if 'test' in x] 41 | 42 | if not TEST_RUNNING: 43 | # Este es un workaround para evitar que unitests.discover importe el modulo y ejecute 44 | # raven. 45 | # Mejores maneras de evitar esto bienvenidas. 46 | import raven 47 | INSTALLED_APPS += ('raven.contrib.django.raven_compat',) # NOQA 48 | RAVEN_CONFIG = { 49 | 'dsn': os.environ.get("SENTRY_DSN", "NOT_CONFIGURED"), 50 | 'release': raven.fetch_git_sha(BASE_DIR), # NOQA 51 | } 52 | 53 | # Prefix for telegram messages 54 | TELEGRAM_MESSAGE_PREFIX = '[STAGING]' 55 | -------------------------------------------------------------------------------- /pyarweb/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import Client 3 | 4 | from events.tests.factories import UserFactory 5 | 6 | 7 | @pytest.fixture(name='client') 8 | def create_client(): 9 | """ 10 | Django client fixture with anonymous user 11 | """ 12 | return Client() 13 | 14 | 15 | @pytest.fixture(name='user') 16 | def create_user(): 17 | """ 18 | Dummy user fixture 19 | """ 20 | return UserFactory() 21 | 22 | 23 | @pytest.fixture(name='logged_client') 24 | def create_logged_client(user): 25 | """ 26 | Django client fixture with a logged user 27 | """ 28 | client = Client() 29 | client.force_login(user=user) 30 | return client 31 | -------------------------------------------------------------------------------- /pyarweb/urls.py: -------------------------------------------------------------------------------- 1 | """URLS configurations for PyAr Web.""" 2 | import re 3 | 4 | from django.conf import settings 5 | from django.contrib import admin 6 | from django.urls import include, path 7 | from django.urls import re_path 8 | from django.views.static import serve 9 | 10 | from .views import buscador, irc 11 | 12 | from community.views import homepage 13 | 14 | admin.autodiscover() 15 | 16 | app_name = 'pyarweb' 17 | urlpatterns = [ 18 | path('irc/', irc, name='irc'), 19 | path('buscador/', buscador, name='buscador'), 20 | 21 | path('', homepage, name='homepage'), 22 | 23 | path('noticias/', include('news.urls')), 24 | path('empresas/', include(('pycompanies.urls', 'pycompanies'), namespace='companies')), 25 | path('taggit_autosuggest/', include('taggit_autosuggest.urls')), 26 | path('summernote/', include('django_summernote.urls')), 27 | re_path(r'^admin/', admin.site.urls), 28 | path('accounts/', include('allauth.urls')), 29 | path('eventos/', include(('events.urls', 'events'), namespace='events')), 30 | path('captcha/', include('captcha.urls')), 31 | path('trabajo/', include(('joboffers.urls', 'joboffers'), namespace='joboffers')), 32 | 33 | # no puedo usar el static porque no funcia en produccion y en prod django esta 34 | # sirviendo los archivos estaticos. Esto es sacado del codigo de la funcion. 35 | # si es un HACK, pero hasta que pueda solucionarlo usando django-assets o algo asi 36 | re_path( 37 | r'^%s(?P.*)$' % re.escape(settings.STATIC_URL.lstrip('/')), 38 | serve, 39 | kwargs=dict(document_root=settings.STATIC_ROOT) 40 | ), 41 | re_path( 42 | r'^%s(?P.*)$' % re.escape(settings.MEDIA_URL.lstrip('/')), 43 | serve, 44 | kwargs=dict(document_root=settings.MEDIA_ROOT) 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /pyarweb/views.py: -------------------------------------------------------------------------------- 1 | """Views for PyArWeb Django App.""" 2 | 3 | 4 | from django.shortcuts import render 5 | 6 | 7 | def irc(request): 8 | """Render the IRC Chat template.""" 9 | return render(request, 'irc/irc.html') 10 | 11 | 12 | def special_page(request, **kwargs): 13 | """Render a basic template of special pages.""" 14 | return render(request, 'special_page.html', kwargs) 15 | 16 | 17 | def buscador(request): 18 | """Render the Google Search template.""" 19 | return render(request, 'buscador.html', 20 | {'buscar': request.GET.get('buscar', '')}) 21 | -------------------------------------------------------------------------------- /pyarweb/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pyarweb project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | import os 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pyarweb.settings") 13 | 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /pycompanies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pycompanies/__init__.py -------------------------------------------------------------------------------- /pycompanies/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Company, UserCompanyProfile 4 | 5 | 6 | @admin.register(UserCompanyProfile) 7 | class UserCompanyProfileAdmin(admin.ModelAdmin): 8 | search_fields = ('company__name', ) 9 | autocomplete_fields = ('user', 'company') 10 | 11 | 12 | @admin.register(Company) 13 | class CompanyAdmin(admin.ModelAdmin): 14 | search_fields = ('name', ) 15 | -------------------------------------------------------------------------------- /pycompanies/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django_summernote.widgets import SummernoteInplaceWidget 3 | from django.utils.translation import gettext_lazy as _ 4 | from urllib.parse import urlparse 5 | 6 | from crispy_forms.helper import FormHelper 7 | from crispy_forms.layout import Div, ButtonHolder, Layout, Submit 8 | 9 | from .models import Company, UserCompanyProfile 10 | 11 | 12 | class CompanyForm(forms.ModelForm): 13 | """A PyAr companies form.""" 14 | 15 | description = forms.CharField(widget=SummernoteInplaceWidget()) 16 | link = forms.CharField( 17 | help_text=_('Por favor, ingrese una URL válida con esquema (por ejemplo, https://).') 18 | ) 19 | 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.helper = FormHelper(self) 23 | self.helper.layout = Layout( 24 | Div( 25 | 'name', 26 | 'photo', 27 | 'link', 28 | 'description' 29 | ), 30 | ButtonHolder( 31 | Submit(_('Guardar'), _('Guardar'), css_class='btn btn-default') 32 | ) 33 | ) 34 | 35 | def clean_link(self): 36 | link = self.cleaned_data.get('link') 37 | if link and not urlparse(link).scheme: 38 | link = f'https://{link}' 39 | return link 40 | 41 | class Meta: 42 | fields = ['name', 'photo', 'link', 'description'] 43 | model = Company 44 | 45 | 46 | class UserCompanyForm(forms.ModelForm): 47 | """A PyAr user companies form.""" 48 | 49 | def __init__(self, *args, **kwargs): 50 | super().__init__(*args, **kwargs) 51 | self.helper = FormHelper(self) 52 | self.helper.layout = Layout( 53 | Div( 54 | 'user' 55 | ), 56 | ButtonHolder( 57 | Submit(_('Guardar'), _('Guardar'), css_class='btn btn-default') 58 | ) 59 | ) 60 | 61 | class Meta: 62 | fields = ['user'] 63 | model = UserCompanyProfile 64 | -------------------------------------------------------------------------------- /pycompanies/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.utils.timezone 3 | import model_utils.fields 4 | from django.conf import settings 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Company', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), 18 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 19 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 20 | ('name', models.CharField(max_length=255, verbose_name='Nombre', unique=True)), 21 | ('description', models.TextField(verbose_name='Descripción')), 22 | ('photo', models.ImageField(upload_to='pycompanies/logos', verbose_name='Logo')), 23 | ('link', models.URLField(help_text='Dirección web de la empresa', verbose_name='URL')), 24 | ('owner', models.ForeignKey(related_name='companies', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /pycompanies/migrations/0002_company_rank.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('pycompanies', '0001_initial'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name='company', 13 | name='rank', 14 | field=models.PositiveIntegerField(default=0), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /pycompanies/migrations/0003_usercompanyprofile.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-06 19:18 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('pycompanies', '0002_company_rank'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='UserCompanyProfile', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='pycompanies.company')), 21 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='company', to=settings.AUTH_USER_MODEL)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /pycompanies/migrations/0004_alter_usercompanyprofile_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-07 19:19 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('pycompanies', '0003_usercompanyprofile'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='usercompanyprofile', 18 | name='user', 19 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='company', to=settings.AUTH_USER_MODEL), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /pycompanies/migrations/0005_company_owner_to_usercompanyprofile.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-07 13:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forward(apps, schema_editor): 7 | Company = apps.get_model('pycompanies', 'Company') 8 | UserCompanyProfile = apps.get_model('pycompanies', 'UserCompanyProfile') 9 | 10 | user_profiles = [ 11 | UserCompanyProfile(company_id=company_id, user_id=user_id) 12 | for company_id, user_id in Company.objects.values_list('id', 'owner') 13 | ] 14 | 15 | UserCompanyProfile.objects.bulk_create(user_profiles) 16 | 17 | 18 | def backward(apps, schema_editor): 19 | UserCompanyProfile = apps.get_model('pycompanies', 'UserCompanyProfile') 20 | UserCompanyProfile.objects.all().delete() 21 | 22 | 23 | class Migration(migrations.Migration): 24 | 25 | dependencies = [ 26 | ('pycompanies', '0004_alter_usercompanyprofile_user'), 27 | ] 28 | 29 | operations = [migrations.RunPython(forward, backward)] 30 | -------------------------------------------------------------------------------- /pycompanies/migrations/0006_remove_company_owner.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-07-07 21:22 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('pycompanies', '0005_company_owner_to_usercompanyprofile'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='company', 15 | name='owner', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /pycompanies/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pycompanies/migrations/__init__.py -------------------------------------------------------------------------------- /pycompanies/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.db.models.signals import post_delete 4 | from django.dispatch import receiver 5 | from django.urls import reverse 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from model_utils.models import TimeStampedModel 9 | 10 | 11 | class Company(TimeStampedModel): 12 | """A PyAr Company that use Python.""" 13 | name = models.CharField('Nombre', max_length=255, unique=True) 14 | description = models.TextField('Descripción') 15 | photo = models.ImageField('Logo', upload_to='pycompanies/logos') 16 | link = models.URLField('URL', help_text=_('Dirección web de la empresa')) 17 | rank = models.PositiveIntegerField(default=0) 18 | 19 | def get_absolute_url(self): 20 | return reverse('companies:detail', kwargs={'pk': self.pk}) 21 | 22 | def __str__(self): 23 | return '%s' % self.name 24 | 25 | 26 | class UserCompanyProfileManager(models.Manager): 27 | """User company profile manager.""" 28 | 29 | def for_user(self, user, **kwargs): 30 | """ 31 | Get the company object for a given user. 32 | """ 33 | if user.is_anonymous: 34 | return None 35 | 36 | qs = super().get_queryset() 37 | return qs.filter(user=user, **kwargs).first() 38 | 39 | 40 | class UserCompanyProfile(models.Model): 41 | """Company data for a User.""" 42 | objects = UserCompanyProfileManager() 43 | 44 | user = models.ForeignKey(settings.AUTH_USER_MODEL, 45 | related_name='company', 46 | on_delete=models.CASCADE) 47 | company = models.ForeignKey(Company, 48 | related_name='users', 49 | on_delete=models.CASCADE) 50 | 51 | def __str__(self): 52 | return f'{self.user.username}: {self.company.name}' 53 | 54 | 55 | # SIGNALS 56 | 57 | 58 | @receiver(post_delete, sender=Company) 59 | def post_delete_user(sender, instance, *args, **kwargs): 60 | "Delete logo image after delete company" 61 | 62 | instance.photo.delete(save=False) 63 | -------------------------------------------------------------------------------- /pycompanies/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/pycompanies/tests/__init__.py -------------------------------------------------------------------------------- /pycompanies/tests/factories.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, Sequence, SubFactory 2 | from factory.django import DjangoModelFactory, ImageField 3 | 4 | from events.tests.factories import UserFactory 5 | from pycompanies.models import Company, UserCompanyProfile 6 | 7 | 8 | class CompanyFactory(DjangoModelFactory): 9 | class Meta: 10 | model = Company 11 | 12 | name = Sequence(lambda n: f'company-{n}') 13 | photo = ImageField(color='blue') 14 | description = Faker('text') 15 | link = Faker('url') 16 | rank = 1 17 | 18 | 19 | class UserCompanyProfileFactory(DjangoModelFactory): 20 | class Meta: 21 | model = UserCompanyProfile 22 | 23 | user = SubFactory(UserFactory) 24 | company = SubFactory(CompanyFactory) 25 | -------------------------------------------------------------------------------- /pycompanies/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pycompanies.tests.factories import UserCompanyProfileFactory 4 | 5 | 6 | @pytest.fixture(name='user_company_profile') 7 | def create_user_company_profile(): 8 | """ 9 | Fixture with a dummy UserCompanyProfileFactory 10 | """ 11 | return UserCompanyProfileFactory.create() 12 | -------------------------------------------------------------------------------- /pycompanies/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | CompanyAdminView, 5 | CompanyAnalyticsView, 6 | CompanyAssociateView, 7 | CompanyAssociationListView, 8 | CompanyCreateView, 9 | CompanyDeleteView, 10 | CompanyDetailView, 11 | CompanyDisassociateView, 12 | CompanyListView, 13 | CompanyUpdateView 14 | ) 15 | 16 | app_name = 'pycompanies' 17 | urlpatterns = [ 18 | path('', CompanyListView.as_view(), name='company_list_all'), 19 | path('/', CompanyDetailView.as_view(), name='detail'), 20 | path('agregar/', CompanyCreateView.as_view(), name='add'), 21 | path('/editar/', CompanyUpdateView.as_view(), name='edit'), 22 | path('/eliminar/', CompanyDeleteView.as_view(), name='delete'), 23 | path('/asociar/', CompanyAssociateView.as_view(), name='associate'), 24 | path('asociarme/', CompanyAssociationListView.as_view(), name='association_list'), 25 | path('admin/', CompanyAdminView.as_view(), name='admin'), 26 | path('/desasociar/', CompanyDisassociateView.as_view(), name='disassociate'), 27 | path('/analitica/', CompanyAnalyticsView.as_view(), name='analytics') 28 | ] 29 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | requests_mock_case_sensitive = True 3 | addopts = --no-migrations --reuse-db --create-db 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleach==4.1.0 # Pinning bleach until this issue is resolved: https://github.com/marksweb/django-bleach/issues/51 2 | Django==3.2.2 3 | django-allauth==0.50.0 4 | django-autoslug==1.9.8 5 | django-bootstrap3==21.1 6 | django-braces==1.15.0 7 | django-crispy-forms==1.13.0 8 | django-dbbackup==3.3.0 9 | django-easy-audit==1.3.2 10 | django-email-obfuscator==0.1.5 11 | django-extensions==3.1.5 12 | django-html-sanitizer==0.1.5 13 | django-model-utils==4.2.0 14 | django-sendfile==0.3.11 15 | django-simple-captcha==0.5.18 16 | django-storages[azure]==1.11.1 17 | django-summernote==0.8.20.0 18 | django-tagging==0.5.0 19 | django-taggit==1.5.1 20 | django-taggit-autosuggest==0.3.8 21 | lxml==4.9.1 22 | Mastodon.py==1.8.1 23 | plotly==5.7.0 24 | psycopg2-binary==2.9.1 25 | requests==2.28.1 26 | tweepy==4.5.0 27 | 28 | git+https://github.com/matagus/django-pagination-py3@2fab61163e31685cf8b23b1c768a5263ca03d699 29 | -------------------------------------------------------------------------------- /static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/bootstrap/3.1.1/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/css/event_map.css: -------------------------------------------------------------------------------- 1 | #map-canvas { 2 | height: 300px; 3 | margin: 0px; 4 | padding: 0px; 5 | margin-bottom: 10px; 6 | } 7 | 8 | /* override from style.css*/ 9 | #div_id_description > div { 10 | width: 100%; 11 | } -------------------------------------------------------------------------------- /static/img/azure-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/azure-footer.png -------------------------------------------------------------------------------- /static/img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/banner.png -------------------------------------------------------------------------------- /static/img/icons/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/icons/icons.png -------------------------------------------------------------------------------- /static/img/icons/icons_active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/icons/icons_active.png -------------------------------------------------------------------------------- /static/img/icons/pyar.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/icons/pyar.ico -------------------------------------------------------------------------------- /static/img/logo-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/logo-header.png -------------------------------------------------------------------------------- /static/img/moin-www.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/moin-www.png -------------------------------------------------------------------------------- /static/img/pyar-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/pyar-footer.png -------------------------------------------------------------------------------- /static/img/python-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/python-banner.png -------------------------------------------------------------------------------- /static/img/python-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/python-footer.png -------------------------------------------------------------------------------- /static/img/ribbon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyAr/pyarweb/f87f93cabcf4602efbeaf38f778c3448e40fc849/static/img/ribbon.png -------------------------------------------------------------------------------- /static/js/joboffer_track.js: -------------------------------------------------------------------------------- 1 | function getCookie(name) { 2 | let cookieValue = null; 3 | if (document.cookie && document.cookie !== '') { 4 | const cookies = document.cookie.split(';'); 5 | for (let i = 0; i < cookies.length; i++) { 6 | const cookie = cookies[i].trim(); 7 | // Does this cookie string begin with the name we want? 8 | if (cookie.substring(0, name.length + 1) === (name + '=')) { 9 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 10 | break; 11 | } 12 | } 13 | } 14 | return cookieValue; 15 | } 16 | 17 | 18 | function trackContactInfo() { 19 | const csrftoken = getCookie('csrftoken'); 20 | 21 | const targetUrl = `${window.location.href}track-contact-info-view`; 22 | 23 | const request = new Request( 24 | targetUrl, 25 | { 26 | method: 'POST', 27 | headers: {'X-CSRFToken': csrftoken}, 28 | mode: 'same-origin' // Do not send CSRF token to another domain. 29 | } 30 | ); 31 | return fetch(request); 32 | } 33 | -------------------------------------------------------------------------------- /static/js/tag_filtering.js: -------------------------------------------------------------------------------- 1 | // var classNames = ['btn-default', 'included btn-success', 'excluded btn-danger']; 2 | var classNames = ['btn-default', 'included btn-success']; 3 | $('#tags-form .filter_tag').click(function () { 4 | var $this = $(this), 5 | $select = $('#' + $this.attr('for')); 6 | $this.toggleClass(function (i, className, b) { 7 | var ret_index; 8 | $.each(classNames, function (index, value) { 9 | if ($this.hasClass(value)) { 10 | ret_index = (index + 1) % classNames.length; 11 | $select.val(ret_index); 12 | } 13 | }); 14 | $this.removeClass(classNames.join(' ')); 15 | return classNames[ret_index]; 16 | }); 17 | $('#tags-form').submit(); 18 | }); 19 | 20 | $('#tags-form #reset-btn').click(function() { 21 | $('#tags-form select').val(0); 22 | $.each(classNames, function (index, value) { 23 | $('#tags-form .filter_tag').removeClass(value); 24 | }); 25 | $('#tags-form .filter_tag').addClass(classNames[0]); 26 | $('#tags-form').submit(); 27 | }); 28 | 29 | $("#tags-form").submit(function() { 30 | $('#tags-form select').not( 31 | $('#tags-form option:selected').not('[value=""]').parent() 32 | ).prop('disabled', true); 33 | return true; 34 | }); 35 | 36 | 37 | $("#show-active").click(function() { 38 | this.form.active.value=this.checked; 39 | $("#buscar").click(); 40 | }); 41 | -------------------------------------------------------------------------------- /static/js/web.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | if(window.location.pathname=="/") { 3 | $("header nav li a[href='/']").parent().addClass("active"); 4 | } else { 5 | $("header nav li.active").removeClass("active"); 6 | $("header nav li a[href='/"+window.location.pathname.split("/")[1]+"/']").parent().addClass("active"); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}403 - {{ block.super }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |

{% trans "Access Denied" %}

12 |

{% trans "You are not authorized to view this page." %}

13 |
14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}404 {% endblock %} 5 | 6 | {% block left-column %} 7 |
8 |
9 |
10 | 13 |

Lo sentimos, pero la página que estás intentando visitar no existe.

14 | 18 | 19 |
20 |
21 |
22 | {% endblock %} -------------------------------------------------------------------------------- /templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}500 {% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 | 13 |

Lo sentimos, pero el servidor encontró un error al procesar su solicitud.

14 |
15 |
16 | {% include 'search_form.html' %} 17 |
18 |
19 |
20 | {% endblock %} -------------------------------------------------------------------------------- /templates/account/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %} 7 | 8 | 9 | {% block container %} 10 |
11 |
12 |
13 |

{% trans "Confirm E-mail Address" %}

14 | 15 | {% if confirmation %} 16 | 17 | {% user_display confirmation.email_address.user as user_display %} 18 | 19 |

{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}

20 | 21 |
22 | {% csrf_token %} 23 | 24 |
25 | 26 | {% else %} 27 | 28 | {% url 'account_email' as email_url %} 29 | 30 |

{% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}

31 | 32 | {% endif %} 33 |
34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /templates/account/login.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% trans "Sign In" %}{% endblock %} 8 | 9 | {% block container %} 10 |
11 |
12 |
13 |

{% trans "Sign In" %}

14 | 15 | {% if socialaccount.providers %} 16 |

17 | {% blocktrans with site.name as site_name %}Please sign in with one 18 | of your existing third party accounts. Or, sign up 19 | for a {{site_name}} account and sign in below:{% endblocktrans %} 20 |

21 | 22 |
23 |
    24 | {% include "socialaccount/snippets/provider_list.html" with process="login" %} 25 |
26 | 27 |
28 | 29 | {% include "socialaccount/snippets/login_extra.html" %} 30 | 31 | {% else %} 32 |

{% blocktrans %}Si aún no tenes cuenta, podes registrarte 33 | acá.{% endblocktrans %}

34 | {% endif %} 35 |
36 | 45 |
46 |
47 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Out" %}{% endblock %} 6 | 7 | {% block container %} 8 |
9 |
10 |
11 |

{% trans "Sign Out" %}

12 | 13 |

{% trans 'Are you sure you want to sign out?' %}

14 | 15 |
16 | {% csrf_token %} 17 | {% if redirect_field_value %} 18 | 19 | {% endif %} 20 | 21 |
22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 8 | 9 | {% block container %} 10 | 11 |
12 |
13 |
14 | 15 |

{% trans "Password Reset" %}

16 | {% if user.is_authenticated %} 17 | {% include "account/snippets/already_logged_in.html" %} 18 | {% endif %} 19 | 20 |

{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

21 | 22 |
23 | {% csrf_token %} 24 | {{ form|crispy }} 25 | 26 |
27 | 28 |

{% blocktrans %}Please contact us if you have any trouble resetting your password.{% endblocktrans %}

29 |
30 |
31 |
32 | {% endblock %} 33 | 34 | {% block extra_body %} 35 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | 8 | {% block container %} 9 |
10 |
11 |
12 |

{% trans "Password Reset" %}

13 | 14 | {% if user.is_authenticated %} 15 | {% include "account/snippets/already_logged_in.html" %} 16 | {% endif %} 17 | 18 |

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

19 |
20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block container %} 8 |
9 |
10 |
11 |

{% if token_fail %}{% trans "Bad Token" %}{% else %}{% trans "Change Password" %}{% endif %}

12 | 13 | {% if token_fail %} 14 | {% url 'account_reset_password' as passwd_reset_url %} 15 |

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

16 | {% else %} 17 | {% if form %} 18 |
19 | {% csrf_token %} 20 | {{ form|crispy }} 21 | 22 |
23 | {% else %} 24 |

{% trans 'Your password is now changed.' %}

25 | {% endif %} 26 | {% endif %} 27 |
28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | 6 | {% block container %} 7 |
8 |
9 |
10 |

{% trans "Change Password" %}

11 |

{% trans 'Your password is now changed.' %}

12 |
13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_title %}{% trans "Registrate" %}{% endblock %} 7 | 8 | {% block container %} 9 |
10 |
11 |
12 |

{% trans "Registrate" %}

13 | 14 |

15 | {% blocktrans %} 16 | ¿Ya tenes cuenta? Por favor inicia sesión. 17 | {% endblocktrans %} 18 |

19 |
20 | 28 |
29 |
30 | {% endblock %} 31 | 32 | 33 | -------------------------------------------------------------------------------- /templates/account/signup_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Up Closed" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Sign Up Closed" %}

9 | 10 |

{% trans "We are sorry, but the sign up is currently closed." %}

11 | {% endblock %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | 8 | {% block container %} 9 |
10 |
11 |
12 |

{% trans "Verify Your E-mail Address" %}

13 | 14 |

{% blocktrans %}We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

15 |
16 |
17 |
18 | {% endblock %} 19 | 20 | -------------------------------------------------------------------------------- /templates/buscador.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} 4 | Resultados para «{{ buscar }}» 5 | {% endblock title %} 6 | 7 | 8 | {% block left-column %} 9 | 12 | 13 | 25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /templates/companies/_user_actions.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | {% if own_company %} 4 |
5 |
6 | {{ own_company.name }} 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 | 22 |
23 |
24 | {% csrf_token %} 25 | 28 |
29 |
30 |
31 | 32 | {% else %} 33 |
34 |
35 | {{ user.username }} 36 | 37 | 38 | 39 |
40 |
41 | 46 | {% endif %} 47 |
48 | -------------------------------------------------------------------------------- /templates/companies/company_admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load devtags %} 3 | {% load i18n static pagination_tags devtags %} 4 | {% block title %}Administrar Empresa{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 | 13 |
14 | 15 |
16 |
17 |

18 | {% trans 'Usuaries vinculades'%} 19 |

20 |
21 | 41 |
42 |
43 |

{% trans 'Vincular usuarie'%}

44 |
45 | {% csrf_token %} 46 | 47 | 48 | 51 | 52 |
53 |
54 |
55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /templates/companies/company_analytics.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load email_obfuscator %} 4 | {% load devtags %} 5 | 6 | {% block title %} 7 | {{ object.title }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 | 16 |
17 |
18 | {% for title, graph in graphs %} 19 |

{{ title }} {% trans '(todas las ofertas)' %}

20 | {% if graph %} 21 | {% autoescape off %} 22 | {{ graph }} 23 | {% endautoescape %} 24 | {% else %} 25 | {% trans 'No información de las visitas para mostrar.' %} 26 | {% endif %} 27 | {% endfor %} 28 | 29 |

{% trans 'Resumen de Visualizaciones' %}

30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% for offer, listing_views, detail_views, contact_info_views in joboffers_data %} 41 | 42 | 43 | 44 | 51 | 52 | 53 | 54 | 55 | {% empty %} 56 | 57 | {% endfor %} 58 |
{% trans 'Fecha de Creación' %}{% trans 'Oferta' %}{% trans 'Tags' %}{% trans 'Vis. en Listado' %}{% trans 'Vis. de Detalle (Individual)' %}{% trans 'Vis. de Datos de Contacto' %}
{{ offer.created_at | date:"SHORT_DATE_FORMAT" }}{{ offer.title }} 45 | {% for tag in offer.tags.all %} 46 | {{ tags }} 47 | {% empty %} 48 | - 49 | {% endfor %} 50 | {{ listing_views }}{{ detail_views }}{{ contact_info_views }}
------
59 | 60 |
61 |
62 |
63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /templates/companies/company_association_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load devtags %} 3 | {% load i18n static pagination_tags devtags %} 4 | {% block title %}Administrar Empresa{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 |

9 | {% trans 'Busca la empresa a la que querés asociarte' %} 10 |

11 |
12 | 13 | 14 | 17 | 18 |
19 |
20 | {% if companies_and_owners %} 21 |
22 | {% for company, owner_names in companies_and_owners %} 23 |
24 | 27 |
28 | {% if company.users.all %} 29 |

Para asociarte a la empresa, por favor comunicate con alguna de estas personas:

30 | {% endif %} 31 | {% for owner_name in owner_names %} 32 |

33 | {{ owner_name }} 34 |

35 | {% empty %} 36 |

{% trans 'No hay ninguna persona asociada a esta empresa.' %}

37 |

{% trans 'Por favor, enviá un email a admin@python.org.ar para solicitar que te asocien.' %}

38 | {% endfor %} 39 |
40 |
41 | {% endfor %} 42 |
43 | {% elif busqueda %} 44 |
45 |

{% trans 'No existen empresas para esta busqueda. Podés crear una empresa acá.' %}

46 |
47 | {% endif %} 48 |
49 |
50 | {% endblock %} 51 | 52 | {% block right-column %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /templates/companies/company_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% load devtags %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 |
12 |
{% csrf_token %} 13 | {% trans "Are you sure you want to delete" %} "{{ object.name }}" ? 14 | 15 | No 16 |
17 |
18 |
19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /templates/companies/company_confirm_disassociate.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% load devtags %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 |
12 |
{% csrf_token %} 13 |

{{message}}

14 | 15 | No 16 |
17 |
18 |
19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /templates/companies/company_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% load devtags %} 5 | {% block title %} 6 | {{ object.title }} 7 | {% endblock title %} 8 | 9 | {% block content %} 10 |
11 |
12 | 26 |
27 |
28 |
29 |

{% autoescape off %}{{ object.description|linebreaks|safe }}{% endautoescape %}

30 |
31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 | {% if object.rank > 0%} 39 |
40 |

Socia Benefactora de la Asociación Civil Python Argentina

41 | 42 | {% endif %} 43 |
44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /templates/companies/company_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% load devtags %} 6 | 7 | {% block stylesheets %} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 |
14 | 22 | {% crispy form %} 23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /templates/companies/company_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load devtags %} 3 | {% load i18n static pagination_tags devtags %} 4 | {% block title %}Empresas que utilizan Python{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 |
9 |
10 |
11 | 20 | {% if object_list %} 21 | {% for object in object_list %} 22 |

23 | {{ object.name }} 24 |

25 |

{{ object.description|striptags|truncatewords:"20" }}

26 |

{% trans 'Más información...' %}

27 | {% endfor %} 28 | 29 | {% paginate %} 30 | {% else %} 31 |
32 |

{% trans 'La búsqueda no produjo resultados.' %}

33 |
34 | {% endif %} 35 |
36 |
37 | {% endblock %} 38 | 39 | {% block right-column %} 40 | {% if user.is_authenticated %} 41 | {% include "companies/_user_actions.html" %} 42 | {% endif %} 43 | 44 | {{ block.super }} 45 | 46 | {% include "_tags_filtering_form.html" %} 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /templates/email_confirm_la/email/email_confirmation_message.html: -------------------------------------------------------------------------------- 1 | {% with event=email_confirmation.content_object.event %} 2 |

3 | El día {{ email_confirmation.content_object.created_at }}, a través del 4 | sitio de PyAr, 5 | ha solicitado inscribirse al evento {{ event.name|title }}. 6 |

7 |

Para verificar la inscripción haga click en el siguiente link:

8 | {% with confirmation_url=email_confirmation.get_confirmation_url %} 9 |

{{ confirmation_url }}

10 | {% endwith %} 11 |

Este link será válido durante los próximos {{ confirmation_url_validity_time|floatformat:"0" }} días.

12 |
13 |

Los datos de inscripción provistos fueron:

14 |
    15 |
  • Nombre: {{ email_confirmation.content_object.name }}
  • 16 |
  • Email: {{ email_confirmation.content_object.email }}
  • 17 |
18 |

Si ud. no solicitó la inscripción puede desestimar este mensaje.

19 | {% endwith %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /templates/email_confirm_la/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | Confirma tu email para suscribirte al evento "{{ email_confirmation.content_object.event.name }}" -------------------------------------------------------------------------------- /templates/email_confirm_la/email_confirmation_expiration.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n pagination_tags %} 3 | {% load devtags %} 4 | {% block title %} {% trans "Error" %}{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 | 13 |
14 |
15 |

16 | El código provisto ha caducado. 17 |

18 |
19 |
20 |
21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /templates/email_confirm_la/email_confirmation_fail.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n pagination_tags %} 3 | {% load devtags %} 4 | {% block title %} {% trans "Error" %}{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 | 13 |
14 |
15 |

16 | El código provisto es incorrecto. 17 |

18 |
19 |
20 |
21 | 22 | {% endblock %} 23 | 24 | -------------------------------------------------------------------------------- /templates/email_confirm_la/email_confirmation_success.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n pagination_tags %} 3 | {% load devtags %} 4 | {% block title %} {% trans "Confirmación de inscripción" %}{% endblock %} 5 | {% block left-column %} 6 | 7 |
8 | 19 |
20 |
21 |

22 | La dirección de email {{ email_confirmation.email }} ha sido verificada. 23 |

24 |
25 |
26 |
27 | 28 | {% endblock %} 29 | 30 | 31 | -------------------------------------------------------------------------------- /templates/events/event_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% load devtags %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 |
12 |
{% csrf_token %} 13 | {% trans "Are you sure you want to delete" %} "{{ object.name }}" ? 14 | 15 | No 16 |
17 |
18 |
19 |
20 | 21 | {% endblock %} -------------------------------------------------------------------------------- /templates/events/event_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load devtags %} 4 | {% block title %} 5 | {{ event.name }} 6 | {% endblock title %} 7 | {% block stylesheets %} 8 | 9 | {% endblock %} 10 | 11 | {% block javascripts %} 12 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 |
19 | 42 |
43 |
44 | 45 |
46 | {% include "events/event_detail_body.html" with obj=event %} 47 |
48 | 49 | 50 | 51 | 52 |
53 | {% endblock %} 54 | 55 | 56 | -------------------------------------------------------------------------------- /templates/events/event_detail_body.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 |
5 | 6 |

{% trans "¿De qué se trata?" %}

7 |

{{ obj.description|safe }}

8 | 9 |
10 | 11 |
12 | 13 |

{% trans "¿Cuándo?" %}

14 | {% if obj.start_at %} 15 |

{% trans "Comienza" %}: {{ obj.start_at }}

16 | {% endif %} 17 | 18 | {% if obj.end_at %} 19 |

{% trans "Termina" %}: {{ obj.end_at }}

20 | {% endif %} 21 | 22 |

{% trans "¿Dónde?" %}

23 |

{{ obj.place }} | {{ obj.address }}

24 | 25 | 26 | {% if obj.url %} 27 |

{% trans "Más Información" %}

28 |

29 | {{ obj.url }} 30 |

31 | {% endif %} 32 | 33 | {% if obj.registration_enabled %} 34 |

35 | {% if user == event.owner %} 36 | 37 | {% trans 'Ver inscriptos' %} 38 | 39 | {% else %} 40 | {% if user_is_registered %} 41 | 42 | {% trans 'Ver mi inscripción' %} 43 | 44 | {% else %} 45 | {% if event %} 46 | 47 | {% trans 'Inscribirme' %} 48 | 49 | {% endif %} 50 | {% endif %} 51 | {% endif %} 52 |

53 | {% endif %} 54 | -------------------------------------------------------------------------------- /templates/events/event_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | {% load devtags %} 5 | {% load crispy_forms_tags %} 6 | 7 | {% block stylesheets %} 8 | 9 | {{ form.media.css }} 10 | {% endblock %} 11 | 12 | {% block title %} 13 | {% if form.instance.id %} 14 | {% trans 'Editar Evento' %} 15 | {% else %} 16 | {% trans 'Agregar Evento' %} 17 | {% endif %} 18 | {% endblock %} 19 | 20 | {% block content %} 21 | 22 |
23 |
24 | 39 | 40 | {% crispy form %} 41 | 42 |
43 |
44 | 45 | {% endblock %} 46 | 47 | {% block javascripts %} 48 | 49 | 50 | 51 | {{ form.media.js }} 52 | 53 | {% endblock %} 54 | 55 | -------------------------------------------------------------------------------- /templates/events/eventparticipation_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load devtags %} 4 | {% block content %} 5 | 6 |
7 |
8 |
9 |
{% csrf_token %} 10 |

11 | {% if user == object.user %} 12 | {% trans "Seguro que quiere cancelar su inscripción al evento " %} 13 | {% else %} 14 | {% trans "Seguro que quiere cancelar la inscripción de" %} 15 | "{{ object.name }}"<{{ object.email|urlize }}> 16 | {% trans "al evento" %} 17 | {% endif %} 18 | "{{ object.event.name }}" ? 19 |

20 | 21 | 22 | No 23 |
24 |
25 |
26 |
27 | 28 | {% endblock %} -------------------------------------------------------------------------------- /templates/events/next_events.html: -------------------------------------------------------------------------------- 1 | {% for event in events %} 2 |
3 |

4 | 5 | {{ event.name }} 6 | 7 |

8 | 9 |

10 | {{ event.start_at|date:"DATE_FORMAT" }} | 11 | {{ event.start_at|time:"TIME_FORMAT" }} | 12 | {{ event.place }} 13 |

14 |
15 | 16 | {% empty %} 17 |
18 |

No hay eventos a futuro

19 |
20 | {% endfor %} 21 | -------------------------------------------------------------------------------- /templates/irc/irc.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block title %}Canales de chat Python Argentina{% endblock %} 6 | 7 | {% block left-column %} 8 |

IRC en Libera.chat

9 |

Nuestro chat original fue el IRC en el servidor de Freenode. En Mayo de 2021 nos mudamos a https://libera.chat. 10 | podes simplemente utilizar tu cliente favorito y conectarte a irc.libera.chat.

11 |

Telegram

12 |

También tenemos un canal en telegram al que podes acceder utilizando este link: https://t.me/pythonargentina

13 |

Bridges

14 |

Todos los canales están conectados con un "bridge" (puente en inglés). Por lo que no importa si estas en Telegram o IRC 15 | vas a poder hablar con los usuarios de cada lado del puente.

16 |

Además tenemos un bridge en Matrix al que podes acceder utilizando este link #pyar:libera.chat 17 |

Código de Conducta

18 |

En todos los canales o eventos te pedimos que leas y respetes nuestro CoC. https://ac.python.org.ar/#coc 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/jobs/_jobs_tags.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% for tag in object.tags.all %} 4 | {{ tag.name }} 5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /templates/jobs/inactivate_job_email.txt: -------------------------------------------------------------------------------- 1 | Te informamos que tu aviso de trabajo "{{ job_title }}" fue dado de baja. 2 | 3 | Motivo: {{ reason }} 4 | {% if comment %} 5 | Detalles: {{ comment }} 6 | {% endif %} 7 | 8 | El equipo de PyAr. 9 | -------------------------------------------------------------------------------- /templates/jobs/job_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "confirm_delete.html" %} 2 | {% load i18n %} 3 | 4 | {% block page_title %}{% trans 'Borrar trabajo' %}{% endblock %} 5 | 6 | {% block return_url %}{% url 'jobs_view' object.slug %}{% endblock %} -------------------------------------------------------------------------------- /templates/jobs/job_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load email_obfuscator %} 4 | {% load devtags %} 5 | 6 | {% block title %} 7 | {{ object.title }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

12 | 13 |
14 | 15 | 29 | 30 |
31 | 32 |
33 | 34 | 35 | {% autoescape off %} 36 | {{ object.description }} 37 | {% endautoescape %} 38 | {% include "jobs/_jobs_tags.html" %} 39 | 40 |
41 | 42 |
43 |
44 |
Datos de la oferta laboral
45 |
46 |
47 | {% include 'jobs/job_overview.html' with obj=object %} 48 |
49 |
50 |
51 |
52 | 53 |
54 | 55 |
56 | 57 | 58 |
59 | 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /templates/jobs/job_detail_feed.html: -------------------------------------------------------------------------------- 1 | {% load devtags %} 2 | {% include 'jobs/job_overview.html' %} 3 |

{{ obj.description|html2text }}

-------------------------------------------------------------------------------- /templates/jobs/job_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% load devtags %} 6 | 7 | {% block extra_head %} 8 | 19 | {% endblock %} 20 | 21 | {% block content %} 22 |
23 |
24 | 32 | 33 | {% crispy form %} 34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /templates/jobs/job_inactivate_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% load devtags %} 6 | 7 | {% block content %} 8 |
9 |
10 | 15 | {{ form.media }} 16 | {% crispy form %} 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/jobs/job_overview.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Fecha de publicación
4 |
5 | 6 |
7 |
Lugar de trabajo
8 |
{{ obj.location|title }}
9 | {% if obj.company %} 10 |
Empresa
11 |
12 | {{ obj.company.name }} 13 |
14 | {% endif %} 15 |
Permite trabajar remoto
16 |
{% if obj.remote_work %}Si{% else %}No{% endif %}
17 | {% if obj.seniority %} 18 |
Seniority requerido
19 |
{{ obj.seniority|title }}
20 | {% endif %} 21 |
Email de contacto
22 |
{{ obj.email }}
23 |
24 |
25 |
26 | 27 | -------------------------------------------------------------------------------- /templates/news/_news_tags.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% trans 'Etiquetas:' %} 3 | {% for tag in object.tags.all %} 4 | {{ tag.name }} 5 | {% endfor %} -------------------------------------------------------------------------------- /templates/news/newsarticle_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "confirm_delete.html" %} 2 | {% load i18n %} 3 | 4 | {% block page_title %}{% trans 'Borrar noticia' %}{% endblock %} 5 | 6 | {% block return_url %}{% url 'news_view' object.id %}{% endblock %} -------------------------------------------------------------------------------- /templates/news/newsarticle_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load email_obfuscator %} 4 | {% load devtags %} 5 | {% block title %} 6 | {{ object.title }} 7 | {% endblock title %} 8 | 9 | {% block content %} 10 |
11 |
12 | 29 |
30 |

{% autoescape off %}{{ object.body|linebreaks|safe }}{% endautoescape %}

31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /templates/news/newsarticle_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% load devtags %} 6 | 7 | {% block content %} 8 |
9 |
10 | 18 | 19 | {% crispy form %} 20 |
21 |
22 | {% endblock %} -------------------------------------------------------------------------------- /templates/news/newsarticle_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load devtags i18n static pagination_tags %} 3 | 4 | {% block title %}Noticias {% endblock %} 5 | 6 | {% block left-column %} 7 | 8 |
9 |
10 | 17 | {% if object_list %} 18 | {% for object in object_list %} 19 |

20 | {{ object.title }} ({{ object.modified|date:"SHORT_DATE_FORMAT" }}) 21 |

22 |

23 | {{ object.introduction|html2text|truncatewords:50 }} 24 |

25 |
26 | {% include "news/_news_tags.html" %} 27 |

{% trans 'Leer más...' %}

28 | {% endfor %} 29 | {% paginate %} 30 | {% else %} 31 |
32 |

{% trans 'La búsqueda no produjo resultados.' %}

33 |
34 | {% endif %} 35 | 36 | 37 | 38 | 39 |
40 | 41 | RSS 42 | 43 |
44 |
45 |
46 | {% endblock %} 47 | 48 | {% block right-column %} 49 | {% with True as hide_sidebar_news %} 50 | {{ block.super }} 51 | {% endwith %} 52 | {% include "_tags_filtering_form.html" %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /templates/registration/activate.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Error al activar su cuenta con el key {{ activation_key }}.

11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |

Próximos eventos

31 |
32 | {% for i in 4|get_range %} 33 |
34 |

PyCon Argentina

35 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

36 |
37 | {% endfor %} 38 |
39 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /templates/registration/activation_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Su cuenta ha sido activada satisfactoriamente.

11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |

Próximos eventos

31 |
32 | {% for i in 4|get_range %} 33 |
34 |

PyCon Argentina

35 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

36 |
37 | {% endfor %} 38 |
39 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /templates/registration/activation_email.txt: -------------------------------------------------------------------------------- 1 | Gracias por registrarse en PyAr! 2 | Para activar su cuenta haga click en el siguiente link: 3 | Activar 4 | -------------------------------------------------------------------------------- /templates/registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | Activar su nueva cuenta en PyAr 2 | -------------------------------------------------------------------------------- /templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 | 16 |
17 |
18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 | 28 | 29 |
30 | 31 | 32 | 33 |
34 |
35 |

Próximos eventos

36 |
37 | {% for i in 4|get_range %} 38 |
39 |

PyCon Argentina

40 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

41 |
42 | {% endfor %} 43 |
44 | 45 |
46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /templates/registration/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Sesion cerrada, nos vemos luego!

11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |

Próximos eventos

31 |
32 | {% for i in 4|get_range %} 33 |
34 |

PyCon Argentina

35 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

36 |
37 | {% endfor %} 38 |
39 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /templates/registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Proceso completo! Un email ha sido envíado para confirmar su registro.

11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |

Próximos eventos

31 |
32 | {% for i in 4|get_range %} 33 |
34 |

PyCon Argentina

35 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

36 |
37 | {% endfor %} 38 |
39 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /templates/registration/registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load devtags %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 | 11 | 17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 | 25 | 26 | 29 | 30 |
31 | 32 | 33 | 34 |
35 |
36 |

Próximos eventos

37 |
38 | {% for i in 4|get_range %} 39 |
40 |

PyCon Argentina

41 |

23 de Agosto de 2012 | 20:00 hs | Audotorio UnQui

42 |
43 | {% endfor %} 44 |
45 | 46 |
47 |
48 |
49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /templates/special_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% block title %} 3 | {{ title }} 4 | {% endblock title %} 5 | 6 | 7 | {% block left-column %} 8 | {% if title %} 9 | 12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /templates/waliki/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}403 {% endblock %} 5 | 6 | {% block left-column %} 7 |
8 |
9 |
10 |
11 | {% trans "You aren't authorized to see this page." %} 12 |
13 |
14 |
15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /templates/waliki/whatchanged.html: -------------------------------------------------------------------------------- 1 | {% extends "waliki/detail.html" %} 2 | {% load i18n %} 3 | {% load tz %} 4 | {% load devtags %} 5 | 6 | {% block extra_head %} 7 | {{ block.super }} 8 | 9 | 10 | 11 | {% endblock %} 12 | 13 | 14 | 15 | {% block header %} 16 | {% block title %}

{% trans "What changed" %} 17 | 18 |
19 | 20 | 21 | RSS 22 | 23 |
24 |

{% endblock title %} 25 | 26 | {% endblock %} 27 | 28 | {% block content %} 29 | 30 | 31 | 32 | 33 | {% for change in changes %} 34 | 35 | 36 | 37 | {% with change.version|add:"^" as parent %} 38 | 39 | {% endwith %} 40 | 41 | 46 | 47 | 48 | 49 | {% endfor %} 50 | 51 |
{{ change.page.slug }}{{ change.version }}{% trans "diff" %}{{ change.author }} 42 | {% blocktrans with since=change.date|localtime|timesince:now context "timesince humanized" %} 43 | {{ since }} ago 44 | {% endblocktrans %} 45 | {{ change.message }}
52 | 53 | 54 | 55 | 61 | 62 | 63 | 64 | 65 | {% endblock content %} 66 | --------------------------------------------------------------------------------