Skip to content

API Reference

REST endpoints

Full endpoint documentation: Reviews on ReDoc ⧉

Review endpoints

Method Path Description
GET /reviews List reviews (filter by reviewee_id, reviewer_id, type, is_active)
GET /reviews/<id> Get a single review
PATCH /reviews/<id> Extend review dates (start_date, end_date)
GET /reviews/<id>/review-submissions List submissions for a review
POST /reviews/<id>/review-submissions Create a submission (add a reviewer)
GET /reviews/<id>/review-submissions/<id> Get a single submission
PATCH /reviews/<id>/review-submissions/<id> Update submission answers
GET /reviews/<id>/review-submissions/<id>/answers Get submission with answers (ABAC-gated)
PUT /reviews/<id>/review-submissions/<id>/publication Publish a submission
PUT /reviews/<id>/review-submissions/<id>/validation Approve a submission
DELETE /reviews/<id>/review-submissions/<id>/validation Reject a submission
GET /reviews/<id>/review-cycle Get the associated review cycle
GET /reviews/<id>/pdf Export review as PDF
PUT /reviews/<id>/release Release review (PDF to Google Drive)

Alaner-scoped endpoints

Method Path Description
GET /alaners/<id>/reviews List alaner's reviews
GET /alaners/<id>/review-submissions List approved submissions for reviewer
GET /alaners/<id>/reviews/<id>/self-review Get self-review submission
GET /alaners/<id>/received-feedback List feedback received (completed, ordered by end_date desc)
GET /alaners/<id>/given-feedback List feedback given (completed, ordered by end_date desc)
POST /alaners/<id>/feedback-summary Generate AI feedback summary via Dust (query: months 6-12)

Code reference

apps.eu_tools.alan_home.models.review_template.OptionValue dataclass

OptionValue(value, label)

Bases: DataClassJsonMixin

label instance-attribute

label

value instance-attribute

value

apps.eu_tools.alan_home.models.review_template.ReviewQuestion dataclass

ReviewQuestion(
    id, type, title=None, description=None, options=None
)

Bases: DataClassJsonMixin

description class-attribute instance-attribute

description = None

id instance-attribute

id

options class-attribute instance-attribute

options = None

title class-attribute instance-attribute

title = None

type instance-attribute

type

apps.eu_tools.alan_home.models.review_template.ReviewQuestionType

Bases: AlanBaseEnum

callout class-attribute instance-attribute

callout = 'callout'

checkbox class-attribute instance-attribute

checkbox = 'checkbox'

select class-attribute instance-attribute

select = 'select'

text class-attribute instance-attribute

text = 'text'

apps.eu_tools.alan_home.models.review_template.ReviewTemplate

Bases: BaseModel

__tablename__ class-attribute instance-attribute

__tablename__ = 'review_template'

description class-attribute instance-attribute

description = mapped_column(Text)

name class-attribute instance-attribute

name = mapped_column(Text, nullable=False)

questions class-attribute instance-attribute

questions = mapped_column(
    DataclassJSONBArray(
        ReviewQuestion,
        none_as_null=True,
        reject_unknown=True,
    ),
    nullable=False,
    default=list,
)

review_cycles class-attribute instance-attribute

review_cycles = relationship(
    "ReviewCycle",
    foreign_keys="ReviewCycle.template_id",
    back_populates="template",
    order_by="ReviewCycle.created_at",
)

self_review_cycles class-attribute instance-attribute

self_review_cycles = relationship(
    "ReviewCycle",
    foreign_keys="ReviewCycle.self_review_template_id",
    back_populates="self_review_template",
    order_by="ReviewCycle.created_at",
)

apps.eu_tools.alan_home.models.review_cycle.ReviewCycle

Bases: BaseModel

__tablename__ class-attribute instance-attribute

__tablename__ = 'review_cycle'

description class-attribute instance-attribute

description = mapped_column(Text, nullable=False)

duration class-attribute instance-attribute

duration = mapped_column(Integer, nullable=False)

is_active

is_active()
Source code in apps/eu_tools/alan_home/models/review_cycle.py
@is_active.expression  # type: ignore[no-redef]
def is_active(cls) -> Mapped[bool]:
    return ~cls.is_cancelled & (
        cls.start_date.is_(None)
        | func.current_date().between(
            cls.start_date,
            cls.start_date
            + func.cast(func.concat(ReviewCycle.start_date, " WEEKS"), INTERVAL),
        )
    )

is_cancelled class-attribute instance-attribute

is_cancelled = mapped_column(
    Boolean,
    nullable=False,
    default=False,
    server_default=false(),
)

max_reviewers class-attribute instance-attribute

max_reviewers = mapped_column(Integer, nullable=False)

min_reviewers class-attribute instance-attribute

min_reviewers = mapped_column(Integer, nullable=False)

name class-attribute instance-attribute

name = mapped_column(Text, nullable=False)

reviewees_smart_group class-attribute instance-attribute

reviewees_smart_group = relationship(
    SmartGroup,
    foreign_keys=[reviewees_smart_group_id],
    uselist=False,
)

reviewees_smart_group_id class-attribute instance-attribute

reviewees_smart_group_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    index=True,
    nullable=False,
)

reviews class-attribute instance-attribute

reviews = relationship(
    "Review",
    back_populates="review_cycle",
    uselist=True,
    order_by="Review.created_at",
)

self_review_template class-attribute instance-attribute

self_review_template = relationship(
    ReviewTemplate,
    foreign_keys=[self_review_template_id],
    back_populates="self_review_cycles",
    uselist=False,
)

self_review_template_id class-attribute instance-attribute

self_review_template_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    index=True,
    nullable=True,
)

smart_reviewees class-attribute instance-attribute

smart_reviewees = relationship(
    "Alaner",
    secondary="smart_group_membership",
    primaryjoin="and_(ReviewCycle.reviewees_smart_group_id == SmartGroupMembership.smart_group_id, SmartGroupMembership.is_active_on(func.coalesce(ReviewCycle.start_date, func.current_date())))",
    secondaryjoin="SmartGroupMembership.alaner_id == Alaner.id",
    uselist=True,
    order_by="Alaner.slack_handle",
    back_populates="review_cycles",
    viewonly=True,
)

start_date class-attribute instance-attribute

start_date = mapped_column(Date)

template class-attribute instance-attribute

template = relationship(
    ReviewTemplate,
    foreign_keys=[template_id],
    back_populates="review_cycles",
    uselist=False,
)

template_id class-attribute instance-attribute

template_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    index=True,
    nullable=False,
)

type class-attribute instance-attribute

type = mapped_column(
    AlanBaseEnumTypeDecorator(ReviewCycleType),
    nullable=False,
)

validates_type class-attribute instance-attribute

validates_type = create_validator('type')

apps.eu_tools.alan_home.models.review_cycle.ReviewCycleType

Bases: AlanBaseEnum

fixed_date class-attribute instance-attribute

fixed_date = 'fixed_date'

lifecycle class-attribute instance-attribute

lifecycle = 'lifecycle'

apps.eu_tools.alan_home.models.review.Review

Bases: BaseModel, Historizable

__tablename__ class-attribute instance-attribute

__tablename__ = 'review'

alaner class-attribute instance-attribute

alaner = relationship(
    "Alaner",
    primaryjoin="foreign(Review.alaner_id) == Alaner.id",
    back_populates="reviews",
    uselist=False,
)

alaner_id class-attribute instance-attribute

alaner_id = mapped_column(
    Integer, nullable=False, index=True
)

filename property

filename

max_reviewers class-attribute instance-attribute

max_reviewers = mapped_column(Integer, nullable=False)

min_reviewers class-attribute instance-attribute

min_reviewers = mapped_column(Integer, nullable=False)

name class-attribute instance-attribute

name = mapped_column(Text, nullable=False)

questions class-attribute instance-attribute

questions = mapped_column(
    DataclassJSONBArray(
        ReviewQuestion,
        none_as_null=True,
        reject_unknown=True,
    ),
    nullable=False,
    default=list,
)

released_on class-attribute instance-attribute

released_on = mapped_column(Date, nullable=True)

review_cycle class-attribute instance-attribute

review_cycle = relationship(
    ReviewCycle,
    foreign_keys=[review_cycle_id],
    back_populates="reviews",
    uselist=False,
)

review_cycle_id class-attribute instance-attribute

review_cycle_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    nullable=True,
    index=True,
)

review_submissions class-attribute instance-attribute

review_submissions = relationship(
    "ReviewSubmission",
    back_populates="review",
    uselist=True,
    order_by="ReviewSubmission.created_at",
)

self_review_questions class-attribute instance-attribute

self_review_questions = mapped_column(
    DataclassJSONBArray(
        ReviewQuestion,
        none_as_null=True,
        reject_unknown=True,
    ),
    nullable=False,
    default=list,
)

status class-attribute instance-attribute

status = mapped_column(
    AlanBaseEnumTypeDecorator(ReviewStatus),
    nullable=False,
    index=True,
)

type class-attribute instance-attribute

type = mapped_column(
    AlanBaseEnumTypeDecorator(ReviewType),
    nullable=False,
    index=True,
)

validates_status class-attribute instance-attribute

validates_status = create_validator('status')

validates_type class-attribute instance-attribute

validates_type = create_validator('type')

apps.eu_tools.alan_home.models.review.ReviewStatus

Bases: AlanBaseEnum

completed class-attribute instance-attribute

completed = 'completed'

ready_for_reviewers_approval class-attribute instance-attribute

ready_for_reviewers_approval = (
    "ready_for_reviewers_approval"
)

ready_for_reviewers_selection class-attribute instance-attribute

ready_for_reviewers_selection = (
    "ready_for_reviewers_selection"
)

ready_for_self_review class-attribute instance-attribute

ready_for_self_review = 'ready_for_self_review'

ready_for_submissions class-attribute instance-attribute

ready_for_submissions = 'ready_for_submissions'

apps.eu_tools.alan_home.models.review.ReviewType

Bases: AlanBaseEnum

ad_hoc class-attribute instance-attribute

ad_hoc = 'ad_hoc'

feedback class-attribute instance-attribute

feedback = 'feedback'

review_cycle class-attribute instance-attribute

review_cycle = 'review_cycle'

apps.eu_tools.alan_home.models.review_submission.ReviewSubmission

Bases: BaseModel, Historizable

__tablename__ class-attribute instance-attribute

__tablename__ = 'review_submission'

answers class-attribute instance-attribute

answers = mapped_column_with_privacy(
    PrimitiveJSONBMap(none_as_null=True),
    privacy_properties=PrivacyProperties(
        category=other,
        kay_strategy=NoneIfOtherColumnIsNone("released_on"),
        turing_strategy=NoneIfOtherColumnIsNone(
            "released_on"
        ),
    ),
)

approved_at class-attribute instance-attribute

approved_at = mapped_column(DateTime, nullable=True)

approver class-attribute instance-attribute

approver = relationship(
    "Alaner",
    primaryjoin="foreign(ReviewSubmission.approver_id) == Alaner.id",
    uselist=False,
)

approver_id class-attribute instance-attribute

approver_id = mapped_column(
    Integer, nullable=True, index=True
)
permalink = mapped_column(Text)

published_at class-attribute instance-attribute

published_at = mapped_column(DateTime)

rejected_at class-attribute instance-attribute

rejected_at = mapped_column(DateTime)

rejecter class-attribute instance-attribute

rejecter = relationship(
    "Alaner",
    primaryjoin="foreign(ReviewSubmission.rejecter_id) == Alaner.id",
    uselist=False,
)

rejecter_id class-attribute instance-attribute

rejecter_id = mapped_column(
    Integer, nullable=True, index=True
)

rejection_reason class-attribute instance-attribute

rejection_reason = mapped_column(Text)

released_on class-attribute instance-attribute

released_on = mapped_column(Date, nullable=True)

review class-attribute instance-attribute

review = relationship(
    Review,
    foreign_keys=[review_id],
    back_populates="review_submissions",
    uselist=False,
)

review_id class-attribute instance-attribute

review_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    nullable=False,
    index=True,
)

reviewer class-attribute instance-attribute

reviewer = relationship(
    "Alaner",
    primaryjoin="foreign(ReviewSubmission.reviewer_id) == Alaner.id",
    uselist=False,
)

reviewer_id class-attribute instance-attribute

reviewer_id = mapped_column(
    Integer, nullable=False, index=True
)

type class-attribute instance-attribute

type = mapped_column(
    AlanBaseEnumTypeDecorator(ReviewSubmissionType),
    nullable=False,
)

validates_type class-attribute instance-attribute

validates_type = create_validator()

apps.eu_tools.alan_home.models.review_submission.ReviewSubmissionType

Bases: AlanBaseEnum

coach class-attribute instance-attribute

coach = 'coach'

coachee class-attribute instance-attribute

coachee = 'coachee'

peer class-attribute instance-attribute

peer = 'peer'

self class-attribute instance-attribute

self = 'self'

apps.eu_tools.alan_home.business_logic.reviews.compute_review_status

compute_review_status(review)
Source code in apps/eu_tools/alan_home/business_logic/reviews.py
def compute_review_status(review: Review) -> ReviewStatus:
    num_submissions = len(
        [rs for rs in review.review_submissions if rs.type != ReviewSubmissionType.self]
    )
    if num_submissions < review.min_reviewers:
        return ReviewStatus.ready_for_reviewers_selection

    # Ignore rejected submissions for approval/publication flow decisions
    non_rejected_submissions = [
        rs
        for rs in review.review_submissions
        if rs.rejected_at is None and rs.type != ReviewSubmissionType.self
    ]

    approved_non_rejected = [
        rs
        for rs in non_rejected_submissions
        if rs.approved_at is not None and rs.type != ReviewSubmissionType.self
    ]

    # If we don't have enough approved (among non-rejected), still waiting for reviewers' approval
    if len(approved_non_rejected) < review.min_reviewers:
        return ReviewStatus.ready_for_reviewers_approval

    if review.type in (ReviewType.review_cycle, ReviewType.ad_hoc) and not any(
        review_submission.published_at is not None
        and review_submission.type == ReviewSubmissionType.self
        for review_submission in review.review_submissions
    ):
        return ReviewStatus.ready_for_self_review

    # Completion should consider only non-rejected submissions
    if non_rejected_submissions and all(
        review_submission.published_at is not None
        for review_submission in non_rejected_submissions
    ):
        return ReviewStatus.completed

    return ReviewStatus.ready_for_submissions

apps.eu_tools.alan_home.business_logic.reviews.export_to_pdf

export_to_pdf(review_id)
Source code in apps/eu_tools/alan_home/business_logic/reviews.py
def export_to_pdf(review_id: UUID) -> BytesIO:
    review = current_session.get_one(
        Review,
        review_id,
        options=(
            joinedload(Review.review_cycle),
            joinedload(Review.review_submissions, ReviewSubmission.reviewer),
        ),
    )

    consolidated_submissions: list[str] = []
    submissions_order = {
        ReviewSubmissionType.self: 0,
        ReviewSubmissionType.coach: 1,
        ReviewSubmissionType.coachee: 2,
        ReviewSubmissionType.peer: 3,
    }

    for review_submission in sorted(
        review.review_submissions,
        key=lambda submission: (
            submissions_order.get(submission.type, 999),
            submission.reviewer.last_name,
        ),
    ):
        if (
            review_submission.rejected_at is not None
            or review_submission.published_at is None
        ):
            continue

        reviewer = review_submission.reviewer
        consolidated_submission = markdown.markdown(
            f"## {reviewer.full_name} ({reviewer.role} - level {reviewer.level}) - {review_submission.type.value} review\n\n"
        )

        if review_submission.answers is None:
            consolidated_submission += markdown.markdown(
                "*Review was not submitted*\n\n"
            )

        else:
            questions = (
                review.self_review_questions
                if review_submission.type == ReviewSubmissionType.self
                else review.questions
            )
            for question in questions:
                if question.type == ReviewQuestionType.callout:
                    continue

                if review.type != ReviewType.feedback:
                    consolidated_submission += markdown.markdown(
                        f"### {question.title}\n\n"
                    )

                answer_value = mandatory(review_submission.answers).get(
                    question.id, "N/A"
                )
                if question.type == ReviewQuestionType.select:
                    values_to_labels = {
                        v.value: v.label
                        for v in cast(
                            "list[OptionValue]",
                            mandatory(question.options).get("values", []),
                        )
                    }
                    answer_label = values_to_labels.get(
                        cast("str", answer_value), cast("str", answer_value)
                    )
                    consolidated_submission += markdown.markdown(f"{answer_label}\n\n")

                else:
                    consolidated_submission += _shift_headings(
                        markdown.markdown(f"{answer_value}\n\n"), shift=3
                    )

        consolidated_submissions.append(consolidated_submission)

    title = markdown.markdown(
        f"# {mandatory(review.start_date).isoformat()} - {review.review_cycle.name if review.review_cycle else 'Continuous feedback'}"
    )
    html_content = "<hr>".join(consolidated_submissions)

    html = f"""
            <!DOCTYPE html>
            <html>
            <head>
                <meta charset="utf-8">
                <style>
                    @page {{
                        size: A4;
                        margin: 2.5cm 2cm 2.5cm 2cm;  /* top, right, bottom, left */
                    }}
                </style>
            </head>
            <body>
                {title}
                {html_content}
            </body>
            </html>
            """

    pdf_bytes = BytesIO()
    PDFWriter.html2pdf(html=html, path_or_bytes=pdf_bytes, css_path="css/reviews.css")
    pdf_bytes.seek(0)
    return pdf_bytes

apps.eu_tools.alan_home.events.reviews.ReviewCreated dataclass

ReviewCreated(review, review_cycle)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

apps.eu_tools.alan_home.events.reviews.ReviewCycleMessageSchema dataclass

ReviewCycleMessageSchema(id, name, description, type)

description instance-attribute

description

from_model classmethod

from_model(review_cycle)
Source code in apps/eu_tools/alan_home/events/reviews.py
@classmethod
def from_model(
    cls, review_cycle: ReviewCycle | None
) -> "ReviewCycleMessageSchema | None":
    if review_cycle is None:
        return None
    return cls(
        id=review_cycle.id,
        name=review_cycle.name,
        description=review_cycle.description,
        type=review_cycle.type,
    )

id instance-attribute

id

name instance-attribute

name

type instance-attribute

type

apps.eu_tools.alan_home.events.reviews.ReviewExtended dataclass

ReviewExtended(
    review,
    review_cycle,
    previous_start_date,
    previous_end_date,
    start_date,
    end_date,
)

Bases: WebhookMessage

end_date instance-attribute

end_date

previous_end_date instance-attribute

previous_end_date

previous_start_date instance-attribute

previous_start_date

review instance-attribute

review

review_cycle instance-attribute

review_cycle

start_date instance-attribute

start_date

apps.eu_tools.alan_home.events.reviews.ReviewMessageSchema dataclass

ReviewMessageSchema(
    id, alaner, type, status, start_date, end_date
)

alaner instance-attribute

alaner

end_date instance-attribute

end_date

from_model classmethod

from_model(review)
Source code in apps/eu_tools/alan_home/events/reviews.py
@classmethod
def from_model(cls, review: Review) -> "ReviewMessageSchema":
    return cls(
        id=review.id,
        alaner=mandatory(AlanerMessageSchema.from_model(review.alaner)),
        type=review.type,
        status=review.status,
        start_date=review.start_date,
        end_date=review.end_date,
    )

id instance-attribute

id

start_date instance-attribute

start_date

status instance-attribute

status

type instance-attribute

type

apps.eu_tools.alan_home.events.reviews.ReviewStatusUpdated dataclass

ReviewStatusUpdated(review, review_cycle)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

apps.eu_tools.alan_home.events.reviews.ReviewSubmissionApproved dataclass

ReviewSubmissionApproved(
    review, review_submission, review_cycle
)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

review_submission instance-attribute

review_submission

apps.eu_tools.alan_home.events.reviews.ReviewSubmissionCreated dataclass

ReviewSubmissionCreated(
    review, review_submission, review_cycle
)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

review_submission instance-attribute

review_submission

apps.eu_tools.alan_home.events.reviews.ReviewSubmissionMessageSchema dataclass

ReviewSubmissionMessageSchema(
    id,
    reviewer,
    type,
    approver=None,
    approved_at=None,
    rejecter=None,
    rejected_at=None,
    published_at=None,
)

approved_at class-attribute instance-attribute

approved_at = None

approver class-attribute instance-attribute

approver = None

from_model classmethod

from_model(review_submission)
Source code in apps/eu_tools/alan_home/events/reviews.py
@classmethod
def from_model(
    cls, review_submission: ReviewSubmission
) -> "ReviewSubmissionMessageSchema":
    return cls(
        id=review_submission.id,
        reviewer=mandatory(
            AlanerMessageSchema.from_model(review_submission.reviewer)
        ),
        type=review_submission.type,
        approver=AlanerMessageSchema.from_model(review_submission.approver),
        approved_at=review_submission.approved_at,
        rejecter=AlanerMessageSchema.from_model(review_submission.rejecter),
        rejected_at=review_submission.rejected_at,
        published_at=review_submission.published_at,
    )

id instance-attribute

id

published_at class-attribute instance-attribute

published_at = None

rejected_at class-attribute instance-attribute

rejected_at = None

rejecter class-attribute instance-attribute

rejecter = None

reviewer instance-attribute

reviewer

type instance-attribute

type

apps.eu_tools.alan_home.events.reviews.ReviewSubmissionPublished dataclass

ReviewSubmissionPublished(
    review, review_submission, review_cycle
)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

review_submission instance-attribute

review_submission

apps.eu_tools.alan_home.events.reviews.ReviewSubmissionRejected dataclass

ReviewSubmissionRejected(
    review, review_submission, review_cycle
)

Bases: WebhookMessage

review instance-attribute

review

review_cycle instance-attribute

review_cycle

review_submission instance-attribute

review_submission