Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Fix translation of application status. feat: Add BorrowerSector. Use gettext to translate enums. #382

Merged
merged 19 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/i18n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ jobs:
run: |
sudo apt update
sudo apt install translate-toolkit
- run: pip install -r requirements.txt
- run: pip install -r requirements.txt .
- name: Update catalogs
run: |
pybabel extract -F babel.cfg -o messages.pot .
pybabel update -i messages.pot -d locale
pybabel update -N -i messages.pot -d locale
- name: Count incomplete translations
shell: bash
run: |
Expand Down
22 changes: 9 additions & 13 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Filesystem
.DS_Store

# Editor
*.swp
*~
.vscode/launch.json

# Virtual environment
/.ve
/venv
.env

# Bytecode
__pycache__
Expand All @@ -15,23 +18,16 @@ __pycache__
/.coverage
/htmlcov

# Sphinx
/docs/_build

# I18n
*.mo

# Node
node_modules
npm-debug.log
# Environment
.env

#test dbs
*.db
.vscode/launch.json
.coverage
coverage_re
.DS_Store
# Documentation
/docs/_build
/schemaspy
/credere-main-*.json
/test.json
/*.egg-info
/messages.pot
20 changes: 15 additions & 5 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from jwt.utils import base64url_decode
from pydantic import BaseModel

from app.i18n import _
from app.settings import app_settings

JWK = dict[str, str]
Expand Down Expand Up @@ -53,7 +54,10 @@ def verify_jwk_token(self, jwt_credentials: JWTAuthorizationCredentials) -> bool
try:
public_key = self.kid_to_jwk[jwt_credentials.header["kid"]]
except KeyError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK public key not found")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("JWK public key not found"),
)

msg = jwt_credentials.message.encode()
sig = base64url_decode(jwt_credentials.signature.encode())
Expand All @@ -77,7 +81,7 @@ async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None
if not credentials.scheme == "Bearer":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Wrong authentication method",
detail=_("Wrong authentication method"),
)

jwt_token = credentials.credentials
Expand All @@ -93,16 +97,22 @@ async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None
message=message,
)
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK invalid")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("JWK invalid"),
)

if not self.verify_jwk_token(jwt_credentials):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK invalid")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("JWK invalid"),
)

return jwt_credentials
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authenticated",
detail=_("Not authenticated"),
)


Expand Down
32 changes: 32 additions & 0 deletions app/babel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import ast
from collections.abc import Collection
from pathlib import Path
from typing import IO, Any, Generator

basedir = Path(__file__).absolute().parent.parent


class Visitor(ast.NodeVisitor):
def __init__(self, classes: list[str]):
self.classes = classes
self.messages: list[tuple[int, str, str]] = []

def visit_ClassDef(self, node: ast.ClassDef) -> None:
if node.name in self.classes:
for child in node.body:
# Skip docstrings.
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
continue
assert isinstance(child, ast.Assign) and isinstance(child.value, ast.Constant), ast.unparse(child)
self.messages.append((child.value.lineno, child.value.value, node.name))


# https://babel.pocoo.org/en/latest/api/messages/extract.html#babel.messages.extract.extract_python uses tokenize,
# but it is easier with ast. (Compare to `python -m tokenize -e app/models.py`.)
def extract_enum(
fileobj: IO[bytes], keywords: dict[str, Any], comment_tags: Collection[str], options: dict[str, Any]
) -> Generator[tuple[int, str, str, list[str]], None, None]:
visitor = Visitor(options.get("classes", "").split(","))
visitor.visit(ast.parse((basedir / "app" / "models.py").read_text()))
for lineno, text, comment in visitor.messages:
yield lineno, "", text, [comment]
39 changes: 29 additions & 10 deletions app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from fastapi import Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session, defaultload, joinedload

from app import auth, aws, models, parsers, util
from app import auth, aws, models, parsers
from app.db import get_db
from app.i18n import _

USER_CAN_EDIT_AWARD_BORROWER_DATA = (
models.ApplicationStatus.SUBMITTED,
Expand Down Expand Up @@ -38,7 +39,10 @@ async def get_current_user(credentials: auth.JWTAuthorizationCredentials = Depen
try:
return credentials.claims["username"]
except KeyError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Username missing")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("Username missing"),
)


async def get_user(username: str = Depends(get_current_user), session: Session = Depends(get_db)) -> models.User:
Expand All @@ -52,15 +56,18 @@ async def get_user(username: str = Depends(get_current_user), session: Session =
"""
user = models.User.first_by(session, "external_id", username)
if not user:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("User not found"),
)
return user


async def get_admin_user(user: models.User = Depends(get_user)) -> models.User:
if not user.is_ocp():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Insufficient permissions",
detail=_("Insufficient permissions"),
)
return user

Expand All @@ -86,18 +93,24 @@ def raise_if_unauthorized(
case _:
raise NotImplementedError
else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not authorized")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("User is not authorized"),
)

if ApplicationScope.UNEXPIRED in scopes:
expired_at = application.expired_at
if expired_at and expired_at < datetime.now(expired_at.tzinfo):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Application expired")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=_("Application expired"),
)

if statuses:
if application.status not in statuses:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Application status should not be {application.status}",
detail=_("Application status should not be %(status)s", status=_(application.status)),
)


Expand All @@ -108,7 +121,10 @@ def get_application_as_user(id: int, session: Session = Depends(get_db)) -> mode
.first()
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_("Application not found"),
)

return application

Expand Down Expand Up @@ -145,12 +161,15 @@ def _get_application_as_guest_via_uuid(session: Session, uuid: str) -> models.Ap
.first()
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_("Application not found"),
)

if application.status == models.ApplicationStatus.LAPSED:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=util.ERROR_CODES.APPLICATION_LAPSED,
detail=_("Application lapsed"),
)

return application
Expand Down
5 changes: 3 additions & 2 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
}


def _(message: str, language: str = app_settings.email_template_lang, **kwargs: Any) -> str:
return translators[language].gettext(message) % kwargs
def _(message: str, language: str | None = None, **kwargs: Any) -> str:
translator = translators.get(language or app_settings.email_template_lang, gettext.NullTranslations())
return translator.gettext(message) % kwargs
23 changes: 23 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,29 @@ class BorrowerSize(StrEnum):
BIG = "BIG"


class BorrowerSector(StrEnum):
AGRICULTURA = "agricultura"
MINAS = "minas"
MANUFACTURA = "manufactura"
ELECTRICIDAD = "electricidad"
AGUA = "agua"
CONSTRUCCION = "construccion"
TRANSPORTE = "transporte"
ALOJAMIENTO = "alojamiento"
COMUNICACIONES = "comunicaciones"
ACTIVIDADES_FINANCIERAS = "actividades_financieras"
ACTIVIDADES_INMOBILIARIAS = "actividades_inmobiliarias"
ACTIVIDADES_PROFESIONALES = "actividades_profesionales"
ACTIVIDADES_SERVICIOS_ADMINISTRATIVOS = "actividades_servicios_administrativos"
ADMINISTRACION_PUBLICA = "administracion_publica"
EDUCACION = "educacion"
ATENCION_SALUD = "atencion_salud"
ACTIVIDADES_ARTISTICAS = "actividades_artisticas"
OTRAS_ACTIVIDADES = "otras_actividades"
ACTIVIDADES_HOGARES = "actividades_hogares"
ACTIVIDADES_ORGANIZACIONES_EXTRATERRITORIALES = "actividades_organizaciones_extraterritoriales"


class CreditType(StrEnum):
LOAN = "LOAN"
CREDIT_LINE = "CREDIT_LINE"
Expand Down
19 changes: 13 additions & 6 deletions app/routers/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from app import aws, dependencies, mail, models, parsers, serializers, util
from app.db import get_db, rollback_on_error
from app.i18n import _
from app.util import SortOrder, get_order_by

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -171,7 +172,7 @@ async def approve_application(
if not_validated_fields:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=util.ERROR_CODES.BORROWER_FIELD_VERIFICATION_MISSING,
detail=_("Some borrower data field are not verified"),
)

# Check all documents are verified.
Expand All @@ -182,7 +183,7 @@ async def approve_application(
if not_validated_documents:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=util.ERROR_CODES.DOCUMENT_VERIFICATION_MISSING,
detail=_("Some documents are not verified"),
)

# Approve the application.
Expand Down Expand Up @@ -326,7 +327,10 @@ async def update_application_award(
"""
with rollback_on_error(session):
if not application.award:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Award not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_("Award not found"),
)

# Update the award.
application.award.update(session, **jsonable_encoder(payload, exclude_unset=True))
Expand Down Expand Up @@ -368,15 +372,18 @@ async def update_application_borrower(
"""
with rollback_on_error(session):
if not application.borrower:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Borrower not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_("Borrower not found"),
)

# Update the borrower.
update_dict = jsonable_encoder(payload, exclude_unset=True)
for field, value in update_dict.items():
if not application.borrower.missing_data[field]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="This column cannot be updated",
detail=_("This column cannot be updated"),
)
application.borrower.update(session, **update_dict)

Expand Down Expand Up @@ -575,7 +582,7 @@ async def email_borrower(
logger.exception(e)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="There was an error",
detail=_("There was an error"),
)
models.Message.create(
session,
Expand Down
2 changes: 1 addition & 1 deletion app/routers/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ async def export_applications(
_("Legal Name", lang): application.borrower.legal_name,
_("Email Address", lang): application.primary_email,
_("Submission Date", lang): application.borrower_submitted_at,
_("Stage", lang): _(application.status.capitalize(), lang),
_("Stage", lang): _(application.status, lang),
}
for application in (
models.Application.submitted_to_lender(session, user.lender_id)
Expand Down
Loading
Loading