from otree.api import * import random class C(BaseConstants): NAME_IN_URL = 'img_choice' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 CHOICES_XLSX_PATH = "choices.csv" # ここはExcelではなくCSVに変更 STATIC_IMG_DIR = "images/" class Subsession(BaseSubsession): pass class Group(BaseGroup): pass def likert_5(label: str): return models.IntegerField( label=label, choices=[ [1, "1 まったくそう思わない"], [2, "2 あまりそう思わない"], [3, "3 どちらともいえない"], [4, "4 ややそう思う"], [5, "5 とてもそう思う"], ], widget=widgets.RadioSelect, ) import csv import os import re _CHOICES_CACHE = None def load_choices_map(): global _CHOICES_CACHE if _CHOICES_CACHE is not None: return _CHOICES_CACHE # choices.csv のパス(models.py から見て 1つ上に置いた想定) path = os.path.join(os.path.dirname(__file__), "..", "choices.csv") def clean(s: str) -> str: # 改行・タブなどの制御文字を除去して両端trim s = "" if s is None else str(s) s = s.replace("\ufeff", "") # 念のためBOMも消す s = re.sub(r"[\r\n\t]+", "", s) # 改行などを消す return s.strip() choices_map = {} # ★ここが重要:utf-8-sig でBOMを自動除去 with open(path, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) # ヘッダ名を正規化(BOM混入や余計な空白に強くする) fieldnames = [clean(fn) for fn in (reader.fieldnames or [])] # DictReader は内部で元の fieldnames を使うので置き換える reader.fieldnames = fieldnames for r in reader: # 行のキーも念のため正規化 r = {clean(k): clean(v) for k, v in r.items()} pid = int(r["ID"]) # ここで KeyError が出なくなる choices_map[pid] = { "recommend": int(r.get("Recommend", "0") or "0"), "pics": [r["Pic1"], r["Pic2"], r["Pic3"]], "pic2": r["Pic2"], "pic3": r["Pic3"], } _CHOICES_CACHE = choices_map return _CHOICES_CACHE class Player(BasePlayer): participant_number = models.IntegerField(label="参加者番号を入力してください") chosen_image = models.StringField() # おすすめが付いた画像(なければ空文字) recommended_image = models.StringField(initial="") # --- 事後質問(共通) --- q_usually_indecisive = likert_5("普段、メニューから選ぶときに迷うことが多い。") q_felt_indecision = likert_5("今回、3品から選ぶときに迷いを感じた。") # --- おすすめ「あり」条件で表示する文言 --- q_rec_helped = likert_5("おすすめがあることで、選びやすかった。") q_rec_reassured = likert_5("おすすめされていると、安心する。") # --- おすすめ「なし」条件で表示する文言 --- q_if_rec_helped = likert_5("選んだ選択肢におすすめが付いていたら、選びやすかったと思う。") q_if_rec_reassured = likert_5("選んだ選択肢におすすめが付いていたら、安心したと思う。") rt_choice = models.FloatField() class ParticipantNumber(Page): form_model = 'player' form_fields = ['participant_number'] @staticmethod def before_next_page(player: Player, timeout_happened): CHOICES_MAP = load_choices_map() pid = int(player.participant_number) if pid not in CHOICES_MAP: raise ValueError(f"choices.xlsx にID={pid} が見つかりません。") row = CHOICES_MAP[pid] pics = [C.STATIC_IMG_DIR + fn for fn in row["pics"]] player.participant.vars["base_images"] = pics order = pics[:] random.shuffle(order) player.participant.vars["shuffled_images"] = order # おすすめは Recommend==1 のときのみ、必ず Pic2 or Pic3 if row["recommend"] == 1: rec_candidates = [ C.STATIC_IMG_DIR + row["pic2"], C.STATIC_IMG_DIR + row["pic3"], ] rng = random.Random(player.participant.code) rec_img = rng.choice(rec_candidates) else: rec_img = None player.participant.vars["recommended_image"] = rec_img player.recommended_image = rec_img or "" class Choice(Page): form_model = 'player' form_fields = ['chosen_image', 'rt_choice'] @staticmethod def vars_for_template(player: Player): return dict( images=player.participant.vars["shuffled_images"], recommended_image=player.participant.vars.get("recommended_image"), ) class Results(Page): form_model = 'player' @staticmethod def get_form_fields(player: Player): # おすすめの有無で質問文言を切り替える has_rec = bool(player.recommended_image) common = ["q_usually_indecisive", "q_felt_indecision"] if has_rec: extra = ["q_rec_helped", "q_rec_reassured"] else: extra = ["q_if_rec_helped", "q_if_rec_reassured"] return common + extra @staticmethod def vars_for_template(player: Player): # 条件の開示はしない(has_recはテンプレ内で使うなら内部利用のみ) return dict( chosen=player.chosen_image, has_rec=bool(player.recommended_image), ) class Thanks(Page): pass page_sequence = [ParticipantNumber, Choice, Results, Thanks]