from otree.api import * import csv import hashlib import hmac import itertools import os import random import re import unicodedata from collections import OrderedDict doc = """ CAPIRE - questionario pre/post per la valutazione del corso di AI literacy. Privacy -> Codice (C.CODE_MODE: HMAC or generated code) -> Demografia -> AILS -> Test (AICOS EA/GA) -> Critical Thinking in AI use -> ATTARI-12 -> Final (certificate PDF) """ def read_csv(filename): path = os.path.join(os.path.dirname(__file__), filename) with open(path, encoding='utf-8-sig') as f: return list(csv.DictReader(f)) def order_items(rows, shuffle_blocks=True): """Shuffle within each subscale, then (optionally) shuffle the blocks. Rows with empty/absent subscale collapse to one block -> plain shuffle.""" groups = OrderedDict() for r in rows: groups.setdefault(r.get('subscale', '') or '', []).append(r['var_name']) for items in groups.values(): random.shuffle(items) keys = list(groups.keys()) if shuffle_blocks: random.shuffle(keys) return [v for k in keys for v in groups[k]] # --------------------------------------------------------------------------- # Codice personale # --------------------------------------------------------------------------- # Secret key for the keyed hash (HMAC-SHA256) of the personal code. # The same key MUST be used in the pre and post waves, otherwise the codes # cannot be linked. To move it out of the code, set the environment variable # CAPIRE_CODE_KEY (e.g. Heroku config var) and remove the default below. CODE_KEY = os.environ.get( 'CAPIRE_CODE_KEY', 'b746e6fb6fb0408e57ecfc15a766da38bf5c0ae6a1fba38e4b56b69a39c4dddc', ) def normalize_letters(value): """'rò ' -> 'RO' (strip accents/spaces, uppercase).""" value = unicodedata.normalize('NFKD', value or '') value = ''.join(ch for ch in value if not unicodedata.combining(ch)) return re.sub(r'\s+', '', value).upper() def hash_code(code): return hmac.new(CODE_KEY.encode(), code.encode(), hashlib.sha256).hexdigest() # --- Alternative: code generated by the system (2 letters + 2 digits) --- # Confusable characters are left out: I, L, O, Q and 0, 1. CODE_LETTERS = 'ABCDEFGHJKMNPRSTUVWXYZ' CODE_DIGITS = '23456789' def code_distance(a, b): return sum(x != y for x, y in zip(a, b)) CODE_ALPHABETS = [CODE_LETTERS, CODE_LETTERS, CODE_DIGITS, CODE_DIGITS] ALL_CODES = [''.join(t) for t in itertools.product(*CODE_ALPHABETS)] # 30,976 codes def _neighbours(code): """Codes that differ from `code` in exactly one character.""" for i, alphabet in enumerate(CODE_ALPHABETS): for ch in alphabet: if ch != code[i]: yield code[:i] + ch + code[i + 1:] def generate_codes(n, existing): """n new codes, unique w.r.t. `existing` and to each other. Every new code differs in at least 2 characters from all other codes (a single typo can never turn into someone else's code); a difference of 3 characters is used while there is room.""" near1, near2 = set(), set() # codes within distance 1 / 2 of a taken code def block(code): for a in _neighbours(code): near1.add(a) near2.add(a) near2.update(_neighbours(a)) near1.add(code) near2.add(code) for code in existing: block(code) new = [] for _ in range(n): free = [c for c in ALL_CODES if c not in near2] # distance >= 3 if not free: free = [c for c in ALL_CODES if c not in near1] # distance >= 2 if not free: raise RuntimeError('No free participant code left') code = random.choice(free) block(code) new.append(code) return new def normalize_code(value): return re.sub(r'[\s\-]+', '', value or '').upper() # --------------------------------------------------------------------------- # Participation certificate # --------------------------------------------------------------------------- # Secret key written in white (invisible) text in every certificate; the same # for all participants. CERT_SECRET = 'CAPIRE-7Q4M-2026' def new_cert_token(): alphabet = CODE_LETTERS + CODE_DIGITS raw = ''.join(random.SystemRandom().choice(alphabet) for _ in range(12)) return '-'.join(raw[i:i + 4] for i in range(0, 12, 4)) # --------------------------------------------------------------------------- # Likert scales # --------------------------------------------------------------------------- HEADERS_L7 = [ "completamente in disaccordo", "in disaccordo", "abbastanza in disaccordo", "né d'accordo né in disaccordo", "abbastanza d'accordo", "d'accordo", "completamente d'accordo", ] HEADERS_L5 = [ "completamente in disaccordo", "in disaccordo", "né d'accordo né in disaccordo", "d'accordo", "completamente d'accordo", ] HEADERS_BY_SCALE = { 'ails': HEADERS_L7, 'ctai': HEADERS_L5, 'attari': HEADERS_L5, } CHOICES_BY_SCALE = { k: [[i + 1, f'{i + 1} - {lab}'] for i, lab in enumerate(h)] for k, h in HEADERS_BY_SCALE.items() } # Text shown on each Likert page SCALE_TEXT = { 'ails': dict( title="Le sue competenze nell'uso dell'IA", intro="Le affermazioni che seguono riguardano il suo rapporto con le tecnologie basate sull'intelligenza artificiale (IA).", ), 'ctai': dict( title="Le sue abitudini nell'uso dell'IA", intro="Pensi a come usa abitualmente gli strumenti di intelligenza artificiale (ad esempio chatbot come ChatGPT, generatori di immagini, assistenti vocali).", ), 'attari': dict( title="Atteggiamenti verso l'intelligenza artificiale", intro=( "Le affermazioni che seguono riguardano il suo atteggiamento verso l'intelligenza artificiale (IA). L'IA è in grado di svolgere compiti che di solito richiedono l'intelligenza umana: permette alle macchine di percepire, agire, imparare e adattarsi in modo autonomo, simile a quello umano. L'IA può far parte di un computer o di una piattaforma online, ma si può incontrare anche in altri dispositivi, come i robot." ), ), } # --------------------------------------------------------------------------- # Demographics - choice codes are stable; 99 = Preferisco non rispondere # --------------------------------------------------------------------------- PNR = [99, 'Preferisco non rispondere'] RUOLO_TIPO_CHOICES = [ [1, 'Ruolo politico (ad esempio: sindaco/a, assessore/a, consigliere/a, parlamentare, ecc.)'], [2, 'Ruolo amministrativo (ad esempio: dirigente, direttore/direttrice, funzionario/a, impiegato/a, ecc.)'], [3, 'Altro'], PNR, ] # 1 = stratum "apicale"; 2 and 3 = stratum "non_apicale" RUOLO_LIVELLO_CHOICES = [ [1, 'Incarico di direzione, coordinamento o indirizzo politico (ad esempio: sindaco/a, ' 'assessore/a, dirigente, direttore/direttrice, capoufficio, responsabile di servizio, ' 'posizione organizzativa, coordinatore/coordinatrice, ecc.)'], [2, 'Personale senza incarichi di direzione o coordinamento (ad esempio: funzionario/a, ' 'impiegato/a, istruttore/istruttrice, personale tecnico-amministrativo, ' 'ricercatore/ricercatrice, ecc.)'], [3, 'Altro (ad esempio: consulente, collaboratore/collaboratrice esterno/a, ecc.)'], PNR, ] # 1 = territoriale; 2 = centrale; 3 = sanita_altri (randomization strata) ENTE_TIPO_CHOICES = [ [1, 'Ente territoriale (Regione, Provincia autonoma, Comune, Città metropolitana, ' 'Unione di comuni, ecc.)'], [2, "Amministrazione centrale o sovranazionale (amministrazione centrale dello Stato, " "Corte dei conti, AGENAS, istituzioni dell'Unione Europea, ecc.)"], [3, 'Altro ente pubblico o privato (ad esempio: azienda sanitaria o ospedaliera, società ' 'partecipata, università o ente di ricerca, altro ente pubblico o privato, ecc.)'], PNR, ] GENERE_CHOICES = [ [1, 'Donna'], [2, 'Uomo'], [3, 'Non mi riconosco in nessuna delle due categorie'], PNR, ] ETA_CHOICES = [ [1, 'da 18 a 35 anni'], [2, 'da 36 a 55 anni'], [3, 'da 56 a 65 anni'], [4, 'oltre 65 anni'], PNR, ] TITOLO_STUDIO_CHOICES = [ [1, 'Diploma superiore o meno'], [2, 'Laurea'], [3, 'Titolo post-laurea'], PNR, ] class C(BaseConstants): NAME_IN_URL = 'capire' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 scales = read_csv('scales.csv') test = read_csv('test.csv') N_PAGES = 7 # pages with a progress bar, Final excluded not_mandatory = False LIKERT_STYLE = 'b' # 'a' = vertical labelled · 'b' = single row · 'c' = compact vertical # Personal code: # 'hash' -> participants compose it (mother/day/father), only the HMAC is stored # 'generated' -> the system assigns a 2 letters + 2 digits code (pre wave) that # participants save as PDF and type in again in the post wave # 'both' -> DEMO ONLY: shows both code pages one after the other CODE_MODE = 'both' # Knowledge test TEST_TIMEOUT_SECONDS = 10 * 60 # set to None to remove the timer SHUFFLE_TEST_OPTIONS = True # set to False to keep options in a-d order # Certificate: also add a unique random verification code to each certificate. # The issued codes are kept as an unordered list in the session (see the # "Report" tab in the admin), never linked to the participant's answers. CERT_UNIQUE_TOKENS = True # TEMPLATES next_progress = 'capire/_templates/next_progress.html' likert_css = 'capire/_templates/_likert_css.html' form_css = 'capire/_templates/_form_css.html' pdf_js = 'capire/_templates/_pdf_js.html' class Subsession(BaseSubsession): pass def creating_session(subsession: Subsession): players = subsession.get_players() wave = subsession.session.config.get('wave', '') if C.CODE_MODE in ('generated', 'both') and wave != 'post': # unique across all sessions stored in the database existing = [d['codice'] for d in Player.values_dicts() if d['codice']] for p, code in zip(players, generate_codes(len(players), existing)): p.codice = code for p in players: p.wave = wave item_order = {} for scale_name in HEADERS_BY_SCALE: rows = [r for r in C.scales if r['scale'] == scale_name] item_order[scale_name] = order_items(rows) item_order['test'] = order_items(C.test) p.participant.vars['capire_item_order'] = item_order class Group(BaseGroup): pass # FIELD CONSTRUCTORS ---------------------------------------------------------- def likert_scale(label, choices): return models.IntegerField( choices=choices, label=label, widget=widgets.RadioSelect(), blank=C.not_mandatory, ) def radio(label, choices): return models.IntegerField( choices=choices, label=label, widget=widgets.RadioSelect(), ) def test_item(label): return models.StringField( choices=['a', 'b', 'c', 'd'], label=label, blank=C.not_mandatory, ) def code_part(label): # Raw components of the personal code: they are wiped in # Codice.before_next_page (same request), so they are never saved. return models.StringField(label=label, blank=True) def _choices_from(choice_list): """Turn a [[value, 'n - text'], ...] list into render-ready dicts.""" out = [] for v, lab in choice_list: txt = lab.split(' - ', 1)[-1] out.append({'value': v, 'num': v, 'text': txt}) return out class Player(BasePlayer): wave = models.StringField() privacy = models.BooleanField( label="Confermo di avere almeno 18 anni e di aver ricevuto e letto le informazioni generali sul progetto e l'informativa sulla privacy. Comprendo che la mia partecipazione al questionario è volontaria e anonima, e che posso ritirarmi in qualsiasi momento. Riconosco che i miei dati saranno trattati nel pieno rispetto della normativa vigente e pertanto acconsento a partecipare al questionario, autorizzando l'utilizzo delle mie risposte per ulteriori scopi di ricerca.", widget=widgets.CheckboxInput, ) # --- Codice personale (only the hash is kept) --- cod_madre = code_part("Ultime due lettere del cognome da nubile di sua madre") cod_giorno = code_part("Giorno di nascita (due cifre)") cod_padre = code_part("Prime due lettere del nome di suo padre") codice_hash = models.StringField() # --- Codice generato (CODE_MODE = 'generated') --- codice = models.StringField( blank=True, label="Inserisca il suo codice personale (due lettere seguite da due numeri):", ) codice_salvato = models.BooleanField( label="Ho salvato il mio codice personale in un luogo sicuro.", widget=widgets.CheckboxInput, ) codice_perso = models.BooleanField( label="Non ho più il mio codice personale.", widget=widgets.CheckboxInput, blank=True, ) # --- Demografia --- ruolo_tipo = radio( "Quale tra le seguenti categorie descrive meglio il suo ruolo nell'ambito " "dell'amministrazione pubblica e/o delle politiche pubbliche?", RUOLO_TIPO_CHOICES, ) ruolo_livello = radio( "Quale tra le seguenti categorie descrive meglio la sua posizione?", RUOLO_LIVELLO_CHOICES, ) ente_tipo = radio( "A quale tipologia appartiene l'ente presso cui lavora?", ENTE_TIPO_CHOICES, ) genere = radio("Lei è:", GENERE_CHOICES) eta_classe = radio("Classe d'età:", ETA_CHOICES) titolo_studio = radio("Può indicare il suo titolo di studio?", TITOLO_STUDIO_CHOICES) # --- Likert scales from scales.csv --- for _row in C.scales: locals()[_row['var_name']] = likert_scale( _row['label'], CHOICES_BY_SCALE[_row['scale']] ) del _row # --- Knowledge test from test.csv (stores the original letter a-d) --- for _row in C.test: locals()[_row['var_name']] = test_item(_row['question']) del _row score_ea = models.IntegerField() score_ga = models.IntegerField() score_total = models.IntegerField() def compute_test_scores(player: Player): scores = {'ea': 0, 'ga': 0} for row in C.test: answer = player.field_maybe_none(row['var_name']) if answer == row['correct']: scores[row['subscale']] += 1 player.score_ea = scores['ea'] player.score_ga = scores['ga'] player.score_total = scores['ea'] + scores['ga'] def progress(k): return round(100 * k / C.N_PAGES) # PAGES ------------------------------------------------------------------- class Privacy(Page): form_model = 'player' form_fields = ['privacy'] class Codice(Page): """CODE_MODE = 'hash': participants compose the code, only the HMAC is stored.""" form_model = 'player' form_fields = ['cod_madre', 'cod_giorno', 'cod_padre'] @staticmethod def is_displayed(player: Player): return C.CODE_MODE in ('hash', 'both') @staticmethod def vars_for_template(player: Player): return dict(progress_pct=progress(1)) @staticmethod def error_message(player: Player, values): errors = {} madre = normalize_letters(values['cod_madre']) giorno = (values['cod_giorno'] or '').strip() padre = normalize_letters(values['cod_padre']) if not re.fullmatch(r'[A-Z]{2}', madre): errors['cod_madre'] = ( "Inserisca esattamente due lettere (le ultime due del cognome da nubile di sua madre)." ) if not re.fullmatch(r'\d{2}', giorno) or not 1 <= int(giorno) <= 31: errors['cod_giorno'] = ( "Inserisca il giorno di nascita con due cifre, da 01 a 31 " "(per i giorni con una sola cifra metta uno zero davanti, es. 06)." ) if not re.fullmatch(r'[A-Z]{2}', padre): errors['cod_padre'] = ( "Inserisca esattamente due lettere (le prime due del nome di suo padre)." ) return errors @staticmethod def before_next_page(player: Player, timeout_happened): code = ( normalize_letters(player.cod_madre) + player.cod_giorno.strip() + normalize_letters(player.cod_padre) ) player.codice_hash = hash_code(code) # wipe the individual components before they are committed to the DB player.cod_madre = None player.cod_giorno = None player.cod_padre = None class CodiceNuovo(Page): """CODE_MODE = 'generated', first wave: show the assigned code (copy / PDF).""" form_model = 'player' form_fields = ['codice_salvato'] @staticmethod def is_displayed(player: Player): return C.CODE_MODE in ('generated', 'both') and player.wave != 'post' @staticmethod def vars_for_template(player: Player): return dict(progress_pct=progress(1)) class CodiceRitorno(Page): """CODE_MODE = 'generated', follow-up wave: participants type in their code.""" form_model = 'player' form_fields = ['codice', 'codice_perso'] @staticmethod def is_displayed(player: Player): return C.CODE_MODE in ('generated', 'both') and player.wave == 'post' @staticmethod def vars_for_template(player: Player): return dict(progress_pct=progress(1)) @staticmethod def error_message(player: Player, values): if values['codice_perso']: return code = normalize_code(values['codice']) if not re.fullmatch(r'[A-Z]{2}\d{2}', code): return dict(codice=( "Il codice è formato da due lettere seguite da due numeri (es. KT48). " "Se non ha più il codice, selezioni la casella qui sotto." )) if (any(ch not in CODE_LETTERS for ch in code[:2]) or any(ch not in CODE_DIGITS for ch in code[2:])): return dict(codice=( "Controlli il codice: non contiene mai le lettere I, L, O, Q né i numeri 0 e 1." )) @staticmethod def before_next_page(player: Player, timeout_happened): player.codice = '' if player.codice_perso else normalize_code(player.codice) class Demografia(Page): form_model = 'player' form_fields = [ 'ruolo_tipo', 'ruolo_livello', 'ente_tipo', 'genere', 'eta_classe', 'titolo_studio', ] @staticmethod def vars_for_template(player: Player): return dict(progress_pct=progress(2)) class _LikertPage(Page): template_name = 'capire/Likert.html' form_model = 'player' scale = None page_nr = None @classmethod def get_form_fields(cls, player: Player): return player.participant.vars['capire_item_order'][cls.scale] @classmethod def vars_for_template(cls, player: Player): order = player.participant.vars['capire_item_order'][cls.scale] by_var = {r['var_name']: r for r in C.scales} choices = _choices_from(CHOICES_BY_SCALE[cls.scale]) return dict( likert_style=C.LIKERT_STYLE, title=SCALE_TEXT[cls.scale]['title'], intro=SCALE_TEXT[cls.scale]['intro'], scale=cls.scale, items=[{'var_name': v, 'label': by_var[v]['label']} for v in order], choices=choices, headers=HEADERS_BY_SCALE[cls.scale], low=choices[0]['text'], high=choices[-1]['text'], n_points=len(choices), progress_pct=progress(cls.page_nr), ) class AILS(_LikertPage): scale = 'ails' page_nr = 3 class Test(Page): form_model = 'player' timeout_seconds = C.TEST_TIMEOUT_SECONDS timer_text = 'Tempo rimanente per completare il test:' @staticmethod def get_form_fields(player: Player): return player.participant.vars['capire_item_order']['test'] @staticmethod def vars_for_template(player: Player): order = player.participant.vars['capire_item_order']['test'] by_var = {r['var_name']: r for r in C.test} items = [] for n, v in enumerate(order, start=1): row = by_var[v] letters = ['a', 'b', 'c', 'd'] if C.SHUFFLE_TEST_OPTIONS: random.shuffle(letters) items.append(dict( num=n, var_name=v, question=row['question'], options=[{'letter': l, 'text': row[l]} for l in letters], )) return dict( items=items, timeout_minutes=(C.TEST_TIMEOUT_SECONDS or 0) // 60, progress_pct=progress(4), ) @staticmethod def before_next_page(player: Player, timeout_happened): compute_test_scores(player) class CTAI(_LikertPage): scale = 'ctai' page_nr = 5 class ATTARI(_LikertPage): scale = 'attari' page_nr = 6 class Final(Page): @staticmethod def vars_for_template(player: Player): token = '' if C.CERT_UNIQUE_TOKENS: # stored as a sorted list in the session only (no link to the player, # no issue order). A page reload issues an extra token. token = new_cert_token() tokens = player.session.vars.get('cert_tokens', []) player.session.vars['cert_tokens'] = sorted(tokens + [token]) return dict( cert_token=token, cert_secret=CERT_SECRET, wave_label='finale' if player.wave == 'post' else 'iniziale', ) def vars_for_admin_report(subsession: Subsession): tokens = subsession.session.vars.get('cert_tokens', []) return dict(tokens=tokens, n_tokens=len(tokens), secret=CERT_SECRET) page_sequence = [ Privacy, Codice, # CODE_MODE = 'hash' CodiceNuovo, # CODE_MODE = 'generated', pre wave CodiceRitorno, # CODE_MODE = 'generated', post wave Demografia, AILS, Test, CTAI, ATTARI, Final, ]