diff --git a/.gitignore b/.gitignore
index 088b4d3..b768b62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -137,3 +137,8 @@ dmypy.json
# ssh keys
*.pem
+
+
+# Database backups
+
+dumps/*
diff --git a/core/constants.py b/core/constants.py
new file mode 100644
index 0000000..48130f2
--- /dev/null
+++ b/core/constants.py
@@ -0,0 +1 @@
+SEGUNDOS_24_HORAS = 60 * 60 * 24
diff --git a/core/management/commands/create_and_update_matches.py b/core/management/commands/create_and_update_matches.py
index 3b0f8b9..4814aa3 100644
--- a/core/management/commands/create_and_update_matches.py
+++ b/core/management/commands/create_and_update_matches.py
@@ -1,10 +1,11 @@
+from django.core.cache import cache
from django.core.management.base import BaseCommand, CommandParser
from core.models import Competition
class Command(BaseCommand):
- help = "For each registered competition, send a request to the Football API to create new matches and update those already registered."
+ help = "For each registered competition, uses the Football API to create new matches and update those already registered."
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
@@ -25,13 +26,17 @@ def handle(self, *args, **options):
competitions = Competition.objects.all()
if not competitions.exists():
self.stdout.write("No competitions registered yet.")
+ return
- for competition in competitions:
- created_matches, updated_matches = competition.create_and_update_matches(
- days_from, days_ahead
- )
- self.stdout.write(
- f"{len(created_matches)} matches created and {len(updated_matches)} updated matches for {competition}..."
- )
+ for comp in competitions:
+ try:
+ created, updated = comp.create_and_update_matches(days_from, days_ahead)
+ self.stdout.write(f"{len(created)} matches created and {len(updated)} updated matches for {comp}...")
+
+ except Exception as e:
+ self.stderr.write(f"Error when updating {comp}: {e}")
+
+ self.stdout.write("Cleaning cache data...")
+ cache.clear()
self.stdout.write("Competitions update done.")
diff --git a/core/models.py b/core/models.py
index d8a8da2..4d79bcf 100644
--- a/core/models.py
+++ b/core/models.py
@@ -36,11 +36,7 @@ def __str__(self) -> str:
return self.name
def logo_url(self):
- return (
- f"https://media.api-sports.io/football/teams/{self.data_source_id}.png"
- if self.data_source_id
- else ""
- )
+ return f"https://media.api-sports.io/football/teams/{self.data_source_id}.png" if self.data_source_id else ""
class Competition(models.Model):
@@ -75,11 +71,7 @@ def get_teams(self):
params = {"league": self.real_data_source_id, "season": self.season}
# TODO: tratar requests.exceptions.ConnectionError:
- response = (
- requests.get(source_url, headers=headers, params=params)
- .json()
- .get("response")
- )
+ response = requests.get(source_url, headers=headers, params=params).json().get("response")
sleep(settings.FOOTBALL_API_RATE_LIMIT_TIME)
teams = []
@@ -114,9 +106,7 @@ def create_and_update_matches(self, days_from: int | None, days_ahead: int | Non
"season": self.season,
"from": str(from_),
"to": str(to),
- "status": "-".join(
- [Match.NOT_STARTED, *Match.IN_PROGRESS_AND_FINISHED_STATUS]
- ),
+ "status": "-".join([Match.NOT_STARTED, *Match.IN_PROGRESS_AND_FINISHED_STATUS]),
}
# TODO: tratar requests.exceptions.ConnectionError:
@@ -245,9 +235,7 @@ def update_matches(self, days_from: int | None, days_ahead: int | None):
away_goals = data["goals"]["away"]
match = (
- Match.objects.exclude(status__in=Match.FINISHED_STATUS)
- .filter(data_source_id=data_source_id)
- .first()
+ Match.objects.exclude(status__in=Match.FINISHED_STATUS).filter(data_source_id=data_source_id).first()
)
if match is None:
@@ -266,9 +254,7 @@ def get_with_matches_on_period(cls, from_: date, to: date):
"""Returns competitions which that have matches on period"""
matches_on_period = Match.get_happen_on_period(from_, to)
- competitions_with_matches_on_period = matches_on_period.values(
- "competition"
- ).distinct()
+ competitions_with_matches_on_period = matches_on_period.values("competition").distinct()
return cls.objects.filter(id__in=competitions_with_matches_on_period)
@@ -371,18 +357,12 @@ def update_status(
):
self.status = new_status
- match_time_limit = self.date_time + timezone.timedelta(
- minutes=match_time_limit_minutes
- )
+ match_time_limit = self.date_time + timezone.timedelta(minutes=match_time_limit_minutes)
match_broke_limit_time = timezone.now() >= match_time_limit
REGULAR_MATCH_DURATION = 90
- if (
- match_broke_limit_time
- and elapsed_time >= REGULAR_MATCH_DURATION
- and self.status == Match.SECOND_HALF
- ):
+ if match_broke_limit_time and elapsed_time >= REGULAR_MATCH_DURATION and self.status == Match.SECOND_HALF:
self.status = Match.FINISHED_STATUS
def is_finished(self) -> bool:
@@ -412,14 +392,8 @@ def result_str(self):
description="Aberta para palpites?",
)
def open_to_guesses(self):
- return (
- self.date_time
- > timezone.now()
- + timezone.timedelta(minutes=self.MINUTES_BEFORE_START_MATCH)
- ) and (
- self.date_time
- <= timezone.now()
- + timezone.timedelta(hours=self.HOURS_BEFORE_OPEN_TO_GUESSES)
+ return (self.date_time > timezone.now() + timezone.timedelta(minutes=self.MINUTES_BEFORE_START_MATCH)) and (
+ self.date_time <= timezone.now() + timezone.timedelta(hours=self.HOURS_BEFORE_OPEN_TO_GUESSES)
)
def get_pools(self):
@@ -475,11 +449,7 @@ def get_involved_pools(self):
def get_who_should_be_notified_by_email(cls):
"""Returns guessers that should be notified by email"""
- return (
- cls.objects.exclude(user__email="")
- .exclude(pools__isnull=True)
- .exclude(receive_notifications=False)
- )
+ return cls.objects.exclude(user__email="").exclude(pools__isnull=True).exclude(receive_notifications=False)
def get_involved_pools_with_new_matches(self):
"""Returns pools with new matches that this guesser is involved
@@ -529,9 +499,7 @@ class Meta:
def __str__(self) -> str:
return (
- f"{self.match.home_team.name} {self.home_goals}"
- + " x "
- + f"{self.away_goals} {self.match.away_team.name}"
+ f"{self.match.home_team.name} {self.home_goals}" + " x " + f"{self.away_goals} {self.match.away_team.name}"
)
def get_score(self) -> int:
@@ -563,16 +531,10 @@ def _evaluate(self):
ACERTO_VISITANTE_VENCEDOR = (self.home_goals < self.away_goals) and (
self.match.home_goals < self.match.away_goals
)
- ACERTO_EMPATE = (self.home_goals == self.away_goals) and (
- self.match.home_goals == self.match.away_goals
- )
+ ACERTO_EMPATE = (self.home_goals == self.away_goals) and (self.match.home_goals == self.match.away_goals)
ACERTO_PARCIAL = ACERTO_MANDANTE_VENCEDOR or ACERTO_VISITANTE_VENCEDOR
- ACERTO_PARCIAL_COM_GOLS = ACERTO_PARCIAL and (
- ACERTO_DE_GOLS_MANDANTE or ACERTO_DE_GOLS_VISITANTE
- )
- ACERTO_SOMENTE_GOLS = (
- ACERTO_DE_GOLS_MANDANTE or ACERTO_DE_GOLS_VISITANTE
- ) and not ACERTO_PARCIAL
+ ACERTO_PARCIAL_COM_GOLS = ACERTO_PARCIAL and (ACERTO_DE_GOLS_MANDANTE or ACERTO_DE_GOLS_VISITANTE)
+ ACERTO_SOMENTE_GOLS = (ACERTO_DE_GOLS_MANDANTE or ACERTO_DE_GOLS_VISITANTE) and not ACERTO_PARCIAL
ACERTO_CRAVADO = ACERTO_DE_GOLS_MANDANTE and ACERTO_DE_GOLS_VISITANTE
PONTUACAO_ACERTO_CRAVADO = 10
@@ -705,10 +667,8 @@ def get_open_matches(self):
"""Returns matches open to guesses"""
return self.get_matches().filter(
- date_time__gt=timezone.now()
- + timezone.timedelta(minutes=self.minutes_before_start_match),
- date_time__lte=timezone.now()
- + timezone.timedelta(hours=self.hours_before_open_to_guesses),
+ date_time__gt=timezone.now() + timezone.timedelta(minutes=self.minutes_before_start_match),
+ date_time__lte=timezone.now() + timezone.timedelta(hours=self.hours_before_open_to_guesses),
)
@classmethod
@@ -738,9 +698,7 @@ def add_guess_to_pools(self, guess: Guess, for_all_pools: bool):
if for_all_pools:
pools_with_the_match = match.get_pools()
pools_with_the_guesser = guesser.pools.all()
- pools_with_match_and_guesser = pools_with_the_match.intersection(
- pools_with_the_guesser
- )
+ pools_with_match_and_guesser = pools_with_the_match.intersection(pools_with_the_guesser)
for pool in pools_with_match_and_guesser:
self._replace_guess_in_pool(guess, pool)
@@ -804,9 +762,7 @@ def get_guessers_with_score_and_guesses(
guessers = self.get_guessers_with_match_scores(matches)
for guesser in guessers:
- guesser.matches_and_guesses = self._get_guesses_per_matches(
- guesser, matches
- )
+ guesser.matches_and_guesses = self._get_guesses_per_matches(guesser, matches)
return guessers
@@ -838,11 +794,7 @@ def _assemble_datetime_period(self, month: int, year: int, week: int):
else:
# período semanal (ano, mes e semanas recebidos)
start = timezone.now().fromisocalendar(year, week, 1)
- end = (
- timezone.now()
- .fromisocalendar(year, week, 7)
- .replace(hour=23, minute=59, second=59)
- )
+ end = timezone.now().fromisocalendar(year, week, 7).replace(hour=23, minute=59, second=59)
return start, end
@@ -862,9 +814,7 @@ def get_finished_or_in_progress_matches_on_period(
)
def get_guessers_with_match_scores(self, matches: Iterable[Match]):
- guesses_of_this_pool_in_the_period = Q(guesses__match__in=matches) & Q(
- guesses__in=self.guesses.all()
- )
+ guesses_of_this_pool_in_the_period = Q(guesses__match__in=matches) & Q(guesses__in=self.guesses.all())
sum_expr = Sum(
"guesses__score",
filter=guesses_of_this_pool_in_the_period,
diff --git a/core/views.py b/core/views.py
index b8a9b5a..c5eeeaa 100644
--- a/core/views.py
+++ b/core/views.py
@@ -3,11 +3,14 @@
from django.forms import CheckboxSelectMultiple, modelform_factory
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
+from django.utils.decorators import method_decorator
from django.utils.text import slugify
from django.views import generic
+from django.views.decorators.cache import cache_page
from core.helpers import redirect_with_msg
+from . import constants
from .forms import GuesserEditForm, GuessForm, RankingPeriodForm, UserEditForm
from .models import Guess, GuessPool
from .viewmixins import GuessPoolMembershipMixin
@@ -23,11 +26,7 @@ def get_context_data(self, **kwargs):
involved_pools = guesser.get_involved_pools()
pools_as_guesser = guesser.pools.all()
for pool in involved_pools:
- pool.is_pending = (
- pool.has_pending_match(self.request.user.guesser)
- if pool in pools_as_guesser
- else False
- )
+ pool.is_pending = pool.has_pending_match(self.request.user.guesser) if pool in pools_as_guesser else False
context["display_subtitle"] = any([pool.is_pending for pool in involved_pools])
context["pools"] = involved_pools
@@ -234,10 +233,7 @@ def get(self, request, *args, **kwargs):
return redirect_with_msg(
self.request,
"error",
- "Nenhum bolão público cadastrado..."
- + " Que tal criar um agora mesmo?"
- + " Basta clicar em Criar bolão"
- + " e configurar como quiser 😎",
+ "Nenhum bolão público cadastrado... Que tal criar um agora mesmo? Basta clicar em Criar bolão e configurar como quiser 😎",
"long",
)
return super().get(request, *args, **kwargs)
@@ -253,10 +249,7 @@ def dispatch(self, request, *args, **kwargs):
return redirect_with_msg(
self.request,
"error",
- "Você não está cadastrado como palpiteiro."
- + " Acesse Gerenciar bolão"
- + " e marque seu usuário como Palpiteiro"
- + " para ter acesso à esta ação.",
+ "Você não está cadastrado como palpiteiro. Acesse Gerenciar bolão e marque seu usuário como Palpiteiro para ter acesso à esta ação.",
"long",
self.pool,
)
@@ -372,6 +365,7 @@ def post(self, *args, **kwargs):
class RankingView(LoginRequiredMixin, GuessPoolMembershipMixin, generic.TemplateView):
template_name = "core/ranking.html"
+ @method_decorator(cache_page(constants.SEGUNDOS_24_HORAS))
def get(self, *args, **kwargs):
context = self.get_context_data(**kwargs)
if not context["guessers"].exists():
@@ -400,8 +394,6 @@ def get_context_data(self, **kwargs):
week = int(form.cleaned_data["semana"])
context["period_form"] = form
- context["guessers"] = self.pool.get_guessers_with_score_and_guesses(
- month, year, week
- )
+ context["guessers"] = self.pool.get_guessers_with_score_and_guesses(month, year, week)
return context
diff --git a/palpiteiros/settings.py b/palpiteiros/settings.py
index 8f9156d..d14c314 100644
--- a/palpiteiros/settings.py
+++ b/palpiteiros/settings.py
@@ -125,8 +125,8 @@
LANGUAGE_CODE = "pt-br"
USE_I18N = True
-TIME_ZONE = config("TIME_ZONE")
-USE_TZ = config("USE_TZ", cast=bool)
+TIME_ZONE = config("TIME_ZONE", default="America/Sao_Paulo")
+USE_TZ = config("USE_TZ", default=1, cast=bool)
# Static files (CSS, JavaScript, Images)
@@ -154,9 +154,7 @@
# Email
-EMAIL_BACKEND = config(
- "EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend"
-)
+EMAIL_BACKEND = config("EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = config("EMAIL_HOST", default="smtp.gmail.com")
EMAIL_HOST_USER = config("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD")
@@ -191,3 +189,16 @@
FOOTBALL_API_KEY = config("FOOTBALL_API_KEY")
FOOTBALL_API_HOST = "v3.football.api-sports.io"
FOOTBALL_API_RATE_LIMIT_TIME = 7
+
+
+# Cache
+
+CACHES = {
+ "default": {
+ "BACKEND": "django_redis.cache.RedisCache",
+ "LOCATION": config("CACHE_LOCATION"),
+ "OPTIONS": {
+ "CLIENT_CLASS": "django_redis.client.DefaultClient",
+ },
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e4b51dc
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,36 @@
+[tool.black]
+line-length = 120
+
+[tool.isort]
+profile = "black"
+
+[tool.flake8]
+max-line-length = 120
+max-complexity = 7
+exclude = ["migrations", "venv"]
+ignore = ["E501"]
+
+[tool.pytest.ini_options]
+python_files = ["test_*.py", "tests.py"]
+norecursedirs = ["venv", "old_tests"]
+addopts = """
+--ds=botscontabeis.settings --no-migrations --reuse-db
+--pdbcls=IPython.terminal.debugger:TerminalPdb
+--color=yes --quiet --numprocesses=auto
+--cov --cov-report=html --no-cov-on-fail --maxfail=1 --cov-fail-under=80 --cov-branch
+"""
+
+[tool.coverage.run]
+branch = true
+source = ["."]
+omit = [
+ "*/tests/*",
+ "*/migrations/*",
+ "venv/*",
+ "static/*",
+ "manage.py",
+ "requirements.txt",
+]
+
+[tool.ipdb]
+context=5
diff --git a/requirements.txt b/requirements.txt
index 4f8511c..7d03d85 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,12 @@
amqp==5.1.1
-asgiref==3.5.2
+asgiref==3.8.1
asttokens==2.4.1
async-timeout==4.0.2
billiard==3.6.4.0
-black==23.3.0
+black==24.4.2
Brotli==1.0.9
celery==5.2.7
-certifi==2022.12.7
+certifi==2024.2.2
cffi==1.15.1
charset-normalizer==3.1.0
click==8.1.3
@@ -17,24 +17,30 @@ colorama==0.4.6
coverage==6.5.0
cron-descriptor==1.2.35
decorator==5.1.1
-Django==4.1.3
-django-celery-beat==2.5.0
+distlib==0.3.8
+Django==5.0.6
+django-celery-beat==2.6.0
django-celery-results==2.5.0
django-coverage-plugin==3.0.0
django-debug-toolbar==4.0.0
django-filter==22.1
+django-redis==5.4.0
django-timezone-field==5.0
djangorestframework==3.14.0
dnspython==2.2.1
eventlet==0.33.2
+execnet==2.1.1
executing==2.0.1
-flake8==6.1.0
+filelock==3.13.4
+flake8==7.1.0
+Flake8-pyproject==1.2.3
flower==1.2.0
gevent==22.10.2
greenlet==2.0.1
gunicorn==20.1.0
humanize==4.6.0
idna==3.4
+iniconfig==2.0.0
install==1.3.5
ipdb==0.13.13
ipython==8.20.0
@@ -43,23 +49,29 @@ kombu==5.2.4
Markdown==3.4.1
matplotlib-inline==0.1.6
mccabe==0.7.0
-model-mommy==2.0.0
+model-bakery==1.18.1
mypy-extensions==0.4.3
onesignal-python-api==1.0.2
packaging==23.1
parso==0.8.3
pathspec==0.10.1
pexpect==4.9.0
-platformdirs==2.5.3
+pipenv==2023.12.1
+platformdirs==4.2.0
+pluggy==1.5.0
prometheus-client==0.16.0
prompt-toolkit==3.0.43
psycopg2-binary==2.9.6
ptyprocess==0.7.0
pure-eval==0.2.2
-pycodestyle==2.11.1
+pycodestyle==2.12.0
pycparser==2.21
-pyflakes==3.1.0
+pyflakes==3.2.0
Pygments==2.17.2
+pytest==8.2.2
+pytest-cov==5.0.0
+pytest-django==4.8.0
+pytest-xdist==3.6.1
python-crontab==2.7.1
python-dateutil==2.8.2
python-decouple==3.7
@@ -75,6 +87,7 @@ traitlets==5.14.1
tzdata==2023.3
urllib3==1.26.14
vine==5.0.0
+virtualenv==20.25.1
wcwidth==0.2.6
whitenoise==6.3.0
zope.event==4.6
diff --git a/scripts/dump_railway_db.sh b/scripts/dump_railway_db.sh
new file mode 100755
index 0000000..96f3282
--- /dev/null
+++ b/scripts/dump_railway_db.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+
+echo """
+===================================================== ATENÇÃO ===================================================
+
+Certifique-se de ter carregado as variáveis de ambiente do projeto Railway no shell antes de executar este script.
+Isso pode ser feito através dos comandos:
+
+$ railway login -p
+$ railway link
+$ railway shell
+
+Caso as variáveis de ambiente de desenvolvimento estiverem carregadas, o dump trará os dados do banco local.
+"""
+
+# Função para verificar se uma variável de ambiente está definida
+check_env_var() {
+ local var_name=$1
+ local var_value=$(eval echo \$$var_name)
+
+ if [ -z "$var_value" ]; then
+ echo "Erro: A variável de ambiente $var_name não está definida."
+ exit 1
+ fi
+}
+
+# Verificar variáveis de ambiente
+check_env_var "DB_HOST"
+check_env_var "DB_NAME"
+check_env_var "DB_USER"
+check_env_var "DB_PASSWORD"
+check_env_var "DB_PORT"
+
+# Diretório onde o dump será salvo
+LOCAL_DUMP_DIR="../dumps"
+DUMP_FILE_NAME="dump_railway_$(date +%Y%m%d%H%M%S).tar"
+
+# Criar o diretório local se não existir
+mkdir -p $LOCAL_DUMP_DIR
+
+# Executa o pg_dump no servidor remoto e salva o dump localmente
+PGPASSWORD=$DB_PASSWORD pg_dump -h $DB_HOST -d $DB_NAME -U $DB_USER -p $DB_PORT -F t -n public > $LOCAL_DUMP_DIR/$DUMP_FILE_NAME
+
+# Verifica se o dump foi bem-sucedido
+if [ $? -eq 0 ]; then
+ echo """
+Dump do banco de dados realizado com sucesso e salvo em $LOCAL_DUMP_DIR/$DUMP_FILE_NAME
+"""
+else
+ echo """
+Ocorreu um erro ao realizar o dump do banco de dados
+"""
+fi
diff --git a/scripts/restore_local_db.sh b/scripts/restore_local_db.sh
new file mode 100755
index 0000000..50e19b8
--- /dev/null
+++ b/scripts/restore_local_db.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+
+# Verifica se um arquivo foi passado como parâmetro
+if [ -z "$1" ]; then
+ echo "Erro: Por favor, forneça o nome do arquivo de dump a ser restaurado."
+ exit 1
+fi
+
+DUMP_FILE_NAME=$1
+
+echo """
+===================================================== ATENÇÃO ===================================================
+
+Certifique-se de ter carregado as variáveis de ambiente do projeto local no shell antes de executar este script.
+Isso pode ser feito através dos comandos:
+
+$ source .env
+"""
+
+# Diretório onde o dump está salvo
+LOCAL_DUMP_DIR="../dumps"
+DUMP_FILE_PATH="$LOCAL_DUMP_DIR/$DUMP_FILE_NAME"
+
+# Verifica se o arquivo de dump existe
+if [ ! -f "$DUMP_FILE_PATH" ]; then
+ echo "Erro: O arquivo de dump $DUMP_FILE_PATH não existe."
+ exit 1
+fi
+
+# Função para verificar se uma variável de ambiente está definida
+check_env_var() {
+ local var_name=$1
+ local var_value=$(eval echo \$$var_name)
+
+ if [ -z "$var_value" ]; then
+ echo "Erro: A variável de ambiente $var_name não está definida."
+ exit 1
+ fi
+}
+
+# Verificar variáveis de ambiente
+check_env_var "DB_HOST"
+check_env_var "DB_NAME"
+check_env_var "DB_USER"
+check_env_var "DB_PASSWORD"
+check_env_var "DB_PORT"
+
+# Executa o pg_restore no servidor remoto utilizando o dump fornecido
+PGPASSWORD=$DB_PASSWORD pg_restore -h $DB_HOST -d $DB_NAME -U $DB_USER -p $DB_PORT -F t -c "$DUMP_FILE_PATH"
+
+# Verifica se o restore foi bem-sucedido
+if [ $? -eq 0 ]; then
+ echo """
+Restore do banco de dados realizado com sucesso utilizando o dump $DUMP_FILE_PATH
+"""
+else
+ echo """
+Ocorreu um erro ao realizar o restore do banco de dados
+"""
+fi
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100755
index 659290b..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,21 +0,0 @@
-[flake8]
-max-line-length = 120
-exclude = migrations, venv
-ignore =
-# docstring
- D102,D106,D101,D103,D100,D104,D105,D401,
-# strings longas
- W503,W504,E501
-
-max-complexity = 7
-extend-ignore = E203
-
-[pycodestyle]
-max-line-length = 120
-exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
-
-[black]
-line-length = 120
-
-[isort]
-profile = black