In part one (Tkinter program to teach Arabic) I was asking for help to review the part of the program responsible for managing the main screens and links to all of the different lessons. From the responses I got, I was able to reduce a lot of redundancies in my code thru the use of classes (a new skill for me) and was able to clean my code up to comply with PEP 8 and hopefully improve the readability of the code.
In this post, I am looking at the second part of my program. This part is stored in a separate .py file and is responsible for loading the actual content of a lesson selected from the main program.
I'm hoping to get some pointers on the use of global variables. Almost everything I can read on the internet talks about how bad they are but for me I can't quite see how I could do what I'm doing without them. I have five different variables declared as global. Any commentary on why they might be good or bad for my program would be very helpful. If they do need to go, then suggestions on "how to" would be helpful.
"""
Module Docstring:
This is the lesson template for almost all of the lesson plans Liblib Aribi.
It includes different types of quesitons such as: multiple choice, entry box, matching, and sentance re-ordering questions.
"""
import os
import random
import winsound
import tkinter
##from tkinter import *
from tkinter import Label, Button, IntVar, Radiobutton, PhotoImage, Entry, Toplevel, ttk
import json
SCORE = None #Defining Global Variables
LIVES = None
VAR = None
WORD_POSITION = None
USER_ANSWER = None
STANDARD_CONFIGS = {
"active":
{"rel": tkinter.SUNKEN, "color": "gold"},
"inactive":
{"rel": tkinter.RAISED, "color": "gray95"},
"width": "100",
"height": "100",
"bg": "gray",
"font": {
"bodytext": ("Helvetica", 15),
"title": ("Helvetica", 35),
"title2": ("Helvetica", 25),
"secondaryhedding": ("Helvetica", 20)
},
"borderwidth": "2",
"disabled": tkinter.DISABLED
}
class CheckAnswers:
"""
This class checkes the given answer against the stated correct answer
and adjusts the scoring and live counter accordinly.
"""
def __init__(self, sf, cho, noa):
self.specialframes = sf
self.choices = cho
self.no_of_answers = noa
def wrong_answer(self):
global LIVES
global SCORE
self.specialframes[1].grid_remove() #Right answer frame
self.specialframes[0].grid(column=1, row=0, rowspan=2) #Wrong answer frame
LIVES -= 1
incorrect_button = Label(
self.specialframes[0],
text=f"That's incorrect!\n Lives: {str(LIVES)}\n Score: {str(SCORE)}",
font=STANDARD_CONFIGS["font"]["title"])
incorrect_button.grid(row=0, rowspan=2, column=0, columnspan=3)
def right_answer(self):
global LIVES
global SCORE
self.specialframes[0].grid_remove() #Wrong answer frame
self.specialframes[1].grid(column=1, row=0, rowspan=2) #Right answer frame
correct_button = Label(
self.specialframes[1],
text=f" That's right! \n Lives: {str(LIVES)}\n Score: {str(SCORE)}",
font=STANDARD_CONFIGS["font"]["title"])
correct_button.grid(row=0, rowspan=2, column=0, columnspan=5)
if self.no_of_answers is not None:
for i in range(self.no_of_answers):
self.choices[i].config(state=STANDARD_CONFIGS["disabled"])
def lesson_template(lesson_name, jfn, lesson_type):
"""
This function loads a pre-determined json file to quiz the user.
"""
root_file_name = "C:\\LearningArabic\\LiblibArriby\\"
lesson_file_path = f"{root_file_name}Lessons\\{lesson_name}\\"
with open(
f"{lesson_file_path}{jfn}.json",
"r",
encoding="utf-8-sig") as question_file:
data = json.load(question_file)
no_of_questions = (len(data["lesson"])) #Counts the number of questions in the .json file
if lesson_type == "quiz":
no_of_quiz_questions = 8
if lesson_type == "short":
no_of_quiz_questions = 4
if lesson_type == "conversation":
no_of_quiz_questions = 1
if lesson_type == "long":
no_of_quiz_questions = 8
global WORD_POSITION
WORD_POSITION = 0
def question_frame_populator(frame_number, data, current_frame, question_number):
question_directory = data["lesson"][question_number]
question = question_directory.get("question")
questiontype = question_directory.get("questiontype")
correctanswer = question_directory.get("answer")
arabic = question_directory.get("arabic")
english_transliteration = question_directory.get("transliteration")
if english_transliteration is None:
english_transliteration = "N/A"
sound = question_directory.get("pronounciation")
image = question_directory.get("image")
image_location = os.path.join(image_path, f"{image}")
if questiontype == "mc":
if os.path.isfile(image_location):
arabic_image = PhotoImage(file=image_location)
image_label = Label(current_frame, image=arabic_image)
image_label.image = arabic_image
image_label.grid(row=1, rowspan=4, column=0, columnspan=2)
maine_question = Label(
current_frame,
text=question,
font=STANDARD_CONFIGS["font"]["title"],
wraplength=500)
transliteration_button = Button(
current_frame,
text="Show Transliteration",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: transliteration(
current_frame,
arabic,
english_transliteration))
next_button(frame_number) # Creates the "next" button and displays it.
quit_program_button(current_frame) # Creates the "quit" button and displays it.
pronounciation_button = Button(
current_frame,
text="Listen",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: pronounciation(sound))
maine_question.grid(columnspan=4, row=0)
if english_transliteration != "N/A":
transliteration_button.grid(column=0, row=5, columnspan=2)
pronounciation_button.grid(column=2, row=5, columnspan=1)
wronganswer = question_directory["wronganswer"]
global VAR
VAR = IntVar()
VAR.set(0) #Sets the initial radiobutton selection to nothing
answers = generate_answers(wronganswer, correctanswer)
no_of_answers = len(answers)
choices = []
for i in range(4):
choice = Radiobutton(
current_frame,
text=answers[i],
variable=VAR,
value=i+1,
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: check_mc_answer(choices, no_of_answers)
)
#choice.image = answers[i]
choices.append(choice)
random.shuffle(choices) #Randomizes the order of the radiobuttons.
if os.path.isfile(image_location):
choices[0].grid(row=1, column=2, columnspan=2)
choices[1].grid(row=2, column=2, columnspan=2)
choices[2].grid(row=3, column=2, columnspan=2)
choices[3].grid(row=4, column=2, columnspan=2)
if not os.path.isfile(image_location):
choices[0].grid(row=1, column=1, columnspan=2)
choices[1].grid(row=2, column=1, columnspan=2)
choices[2].grid(row=3, column=1, columnspan=2)
choices[3].grid(row=3, column=1, columnspan=2)
if questiontype == "eb":
if os.path.isfile(image_location):
arabic_image = PhotoImage(file=image_location)
image_label = Label(current_frame, image=arabic_image)
image_label.image = arabic_image
image_label.grid(row=1, rowspan=3, column=0, columnspan=2)
main_question = Label(
current_frame,
text=question,
font=STANDARD_CONFIGS["font"]["title"],
wraplength=500)
transliteration_button = Button(
current_frame,
text="Show Transliteration",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: transliteration(
current_frame,
arabic,
english_transliteration))
next_button(frame_number) # Creates the "next" button and displays it
quit_program_button(current_frame) # Creates the "quit" button
pronounciation_button = Button(
current_frame,
text="Listen",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: pronounciation(sound))
entry_box = Entry(
current_frame,
width=20,
font=STANDARD_CONFIGS["font"]["secondaryhedding"])
entry_box.focus()
check_answer_button = Button(
current_frame,
text="Check Answer",
font=STANDARD_CONFIGS["font"]["secondaryhedding"],
command=lambda: check_entry_box(entry_box, correctanswer))
main_question.grid(column=0, row=0, columnspan=4)
entry_box.grid(column=1, row=1, columnspan=2)
check_answer_button.grid(column=1, row=3, columnspan=2)
if english_transliteration != "N/A":
transliteration_button.grid(column=0, row=5, columnspan=2)
pronounciation_button.grid(column=2, row=5, columnspan=1)
if questiontype == "matching":
no_of_statements = (len(data["lesson"]))
list_of_sentances = []
list_of_answers = []
list_of_sounds = []
for i in range(no_of_statements):
directory = data["lesson"][f"question{str(i)}"]
sentance = directory.get("question")
list_of_sentances.append(sentance)
answer = directory.get("answer")
list_of_answers.append(answer)
sound = directory.get("pronounciation")
list_of_sounds.append(sound)
paired = []
for i in range(no_of_statements):
pair = (list_of_sentances[i],
list_of_answers[i],
list_of_sounds[i])
paired.append(pair)
random.shuffle(paired)
for i in range(no_of_statements):
sound_button = Button(
current_frame,
text="Listen",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda i=i: pronounciation(paired[i][2]))
sound_button.grid(column=0, row=i+1)
for i in range(no_of_statements):
label_1 = Label(
current_frame,
text=paired[i][0],
font=STANDARD_CONFIGS["font"]["bodytext"])
label_1.grid(column=1, row=1+i)
list_of_entry_boxes = []
for i in range(no_of_statements):
entry_box_1 = Entry(
current_frame,
width=10,
font=STANDARD_CONFIGS["font"]["bodytext"])
entry_box_1.grid(column=2, columnspan=1, row=i+1)
list_of_entry_boxes.append(entry_box_1)
main_quesion = Label(
current_frame,
text="Use the entry boxes on the right to correctly order the conversation",
font=STANDARD_CONFIGS["font"]["secondaryhedding"],
wraplength=500)
main_quesion.grid(columnspan=4, row=0)
check_answer_button = Button(
current_frame,
text="Check Answer",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: check_multiple_entry_boxes(
list_of_entry_boxes,
paired,
current_frame,
no_of_statements))
check_answer_button.grid(column=0, columnspan=4, row=8)
if questiontype == "wordbank":
current_frame.grid_rowconfigure(1, weight=1)
current_frame.grid_rowconfigure(2, weight=1)
if os.path.isfile(image_location):
arabic_image = PhotoImage(file=image_location)
image_label = Label(current_frame, image=arabic_image)
image_label.image = arabic_image
image_label.grid(row=1, rowspan=4, column=0, columnspan=2)
question_title = Label(
current_frame,
text=question,
font=STANDARD_CONFIGS["font"]["title"],
wraplength=500)
transliteration_button = Button(
current_frame,
text="Show Transliteration",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: transliteration(
current_frame,
arabic,
english_transliteration))
check_answer_button = Button(
current_frame,
text="Check Answer",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: check_sentance_order(solution, USER_ANSWER))
clear_selection_button = Button(
current_frame,
text="Clear Selection",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: clear_selected_words(
word_bank,
size_of_word_bank,
word_chosen))
pronounciation_button = Button(
current_frame,
text="Listen",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda: pronounciation(sound))
sentance_label = Label(
current_frame,
text="Sentence:",
font=STANDARD_CONFIGS["font"]["bodytext"])
word_bank_label = Label(
current_frame,
text="Word Bank:",
font=STANDARD_CONFIGS["font"]["bodytext"])
size_of_solution = len(question_directory["solution"])
size_of_word_bank = len(question_directory["answers"])
sentance_components = []
solution = []
for i in range(size_of_solution):
word = question_directory["solution"][i]
solution.append(word)
word_chosen = []
for i in range(size_of_word_bank):
word = question_directory["answers"][i]
sentance_components.append(word)
label = Label(current_frame,
text=sentance_components[i],
font=STANDARD_CONFIGS["font"]["bodytext"])
word_chosen.append(label)
global USER_ANSWER
USER_ANSWER = []
word_bank = []
for i in range(size_of_word_bank):
word = Button(
current_frame,
text=sentance_components[i],
font=STANDARD_CONFIGS["font"]["bodytext"],
command=lambda i=i: sentancebuilder(
question_directory,
word_bank,
i,
sentance_components,
size_of_word_bank,
word_chosen))
word_bank.append(word)
random.shuffle(word_bank)
next_button(frame_number) # Creates the "next" button and displays it.
question_title.grid(column=0, row=0, columnspan=6)
sentance_label.grid(column=0, row=1, columnspan=1)
word_bank_label.grid(column=0, row=2, columnspan=1)
for i in range(size_of_word_bank):
word_bank[i].grid(column=i+1, row=2, columnspan=1)
pronounciation_button.grid(column=0, row=3, columnspan=1)
clear_selection_button.grid(column=1, row=3, columnspan=2)
check_answer_button.grid(column=3, row=3, columnspan=2)
transliteration_button.grid(column=0, row=5, columnspan=2)
def sentancebuilder(question_directory, word_bank, i, sentance_components, size_of_word_bank, word_chosen):
for index in range(size_of_word_bank):
if word_bank[index]["text"] == sentance_components[i]:
word_bank[index].grid_forget()
global WORD_POSITION
if question_directory["translationdirection"] == "EtoA":
word_chosen[i].grid(column=size_of_word_bank-WORD_POSITION, row=1, columnspan=1)
if question_directory["translationdirection"] == "AtoE":
word_chosen[i].grid(column=WORD_POSITION+1, row=1, columnspan=1)
WORD_POSITION += 1
global USER_ANSWER
USER_ANSWER.append(sentance_components[i])
def clear_selected_words(word_bank, size_of_word_bank, word_chosen):
for i in range(size_of_word_bank):
word_chosen[i].grid_forget()
global WORD_POSITION
WORD_POSITION = 0
global USER_ANSWER
USER_ANSWER = []
for i in range(size_of_word_bank):
word_bank[i].grid(column=i+1, row=2)
def check_multiple_entry_boxes(list_of_entry_boxes, paired, current_frame, no_of_statements):
entered_values = []
for i in range(no_of_statements):
value = list_of_entry_boxes[i].get()
entered_values.append(value)
for i in range(no_of_statements):
if entered_values[i] == paired[i][1]:
label_1 = Label(
current_frame,
text="Correct ",
font=STANDARD_CONFIGS["font"]["bodytext"])
label_1.grid(column=3, row=i+1)
for i in range(no_of_statements):
if entered_values[i] != paired[i][1]:
label_1 = Label(
current_frame,
text="Incorrect",
font=STANDARD_CONFIGS["font"]["bodytext"])
label_1.grid(column=3, row=i+1)
def check_sentance_order(solution, USER_ANSWER):
if solution == USER_ANSWER:
CheckAnswers(special_frames, None, None).right_answer()
if solution != USER_ANSWER:
CheckAnswers(special_frames, None, None).wrong_answer()
def check_entry_box(entry_box, correctanswer):
entered_value = entry_box.get()
if entered_value == correctanswer:
CheckAnswers(special_frames, None, None).right_answer()
if entered_value != correctanswer:
CheckAnswers(special_frames, None, None).wrong_answer()
def check_mc_answer(choices, no_of_answers):
if str(VAR.get()) != "4":
CheckAnswers(special_frames, choices, no_of_answers).wrong_answer()
if str(VAR.get()) == "4":
CheckAnswers(special_frames, choices, no_of_answers).right_answer()
def transliteration(current_frame, arabic, english_transliteration):
transliteration_button = Label(
current_frame,
text=f"'{arabic}' is pronounced '{english_transliteration}'",
font=STANDARD_CONFIGS["font"]["secondaryhedding"])
transliteration_button.grid(row=5, column=0, columnspan=6)
def pronounciation(sound):
winsound.PlaySound(f"{sound_path}{sound}", winsound.SND_FILENAME)
def generate_answers(wronganswer, correctanswer):
wans = random.sample(wronganswer, 3)
answers = wans
answers.append(correctanswer)
return answers
def check_remaining_lives(create_widgets_in_current_frame, current_frame):
if LIVES <= 0:
zero_lives()
else:
current_frame.grid(column=0, row=0)
create_widgets_in_current_frame
def zero_lives():
all_frames_forget()
for i in range(1): #This is for the progress bar frame
progress_bar_frame[i].grid_forget()
special_frames[2].grid(column=0, row=0, sticky=(tkinter.W, tkinter.E))
label_5 = Label(
special_frames[2],
text="You have no remaining lives. \nPlease quit the lesson and try again.",
font=STANDARD_CONFIGS["font"]["title"])
label_5.grid(columnspan=4, row=0)
quit_button = Button(
special_frames[2],
text="Quit",
command=root_window.destroy)
quit_button.grid(column=1, columnspan=2, row=2)
def quit_program_button(current_frame):
quit_button = Button(
current_frame,
text="Quit",
font=STANDARD_CONFIGS["font"]["bodytext"],
command=quit_program)
quit_button.grid(column=3, row=5)
def quit_program():
root_window.destroy()
def next_button(frame_number):
next_button = Button(
special_frames[1],
text="Next Question",
command=lambda: next_question(frame_number))
next_button.grid(column=0, columnspan=5, row=3)
def next_question(frame_number):
frame_number += 1
progress_bar.step(1)
call_frame(frame_number)
def all_frames_forget():
for i in range(0, no_of_quiz_questions): #This is for question frames
frames[i].grid_remove()
for i in range(0, 3): #This is for special frames: (correct and incorrect answer frames)
special_frames[i].grid_remove()
def create_widgets_function(frame_number):
current_frame = frames[frame_number]
question_number = random_list_of_questions[frame_number]
question_frame_populator(frame_number, data, current_frame, question_number)
def call_frame(frame_number):
all_frames_forget()
if frame_number == no_of_quiz_questions:
print("Lesson complete")
root_window.destroy()
if frame_number != no_of_quiz_questions:
create_widgets_in_current_frame = create_widgets_function(frame_number)
current_frame = frames[frame_number]
check_remaining_lives(create_widgets_in_current_frame, current_frame)
##### Program starts here #####
image_path = f"{lesson_file_path}Images\\"
sound_path = f"{lesson_file_path}Sounds\\"
root_window = Toplevel() # Create the root GUI window.
root_window.title(full_lesson_name) #This variable is passed from the main program
random_list_of_questions = []
for i in range(0, no_of_questions):
random_question = f"question{str(i)}"
random_list_of_questions.append(random_question)
random.shuffle(random_list_of_questions) #This section randomizes the question order
global SCORE
SCORE = 0 #Setting the initial score to zero.
global LIVES
LIVES = 3 #Setting the initial number of lives.
frame_number = 0
frames = [] # This includes frames for all questions
for i in range(0, no_of_quiz_questions):
frame = tkinter.Frame(
root_window,
borderwidth=STANDARD_CONFIGS["borderwidth"],
relief=STANDARD_CONFIGS["active"]["rel"])
frame.grid(column=0, row=0, sticky=(tkinter.W, tkinter.N, tkinter.E))
frames.append(frame)
special_frames = [] #Includes the frames: wrong ans, right ans, zero lives, and progress bar
for i in range(0, 4):
special = tkinter.Frame(
root_window,
borderwidth=STANDARD_CONFIGS["borderwidth"],
relief=STANDARD_CONFIGS["active"]["rel"])
special.grid(column=1, row=0, sticky=(tkinter.W, tkinter.E))
special.grid_forget()
special_frames.append(special)
progress_bar_frame = []
for i in range(0, 1):
test = tkinter.Frame(
root_window,
borderwidth=STANDARD_CONFIGS["borderwidth"],
relief=STANDARD_CONFIGS["active"]["rel"])
test.grid(column=0, row=1)#, sticky=(tkinter.W, tkinter.E))
progress_bar_frame.append(test)
progress_bar = ttk.Progressbar(
progress_bar_frame[0],
orient='horizontal',
mode='determinate',
maximum=no_of_quiz_questions)
progress_bar.grid(column=0, row=1, columnspan=3)
random.shuffle(frames)
call_frame(frame_number) #Calls the first function which creates the firist frame
root_window.mainloop()
If other problems become apparent during the review of my code please feel free to comment on them as well. I'm hoping to learn more about the use of global variables specifically but I open to any and all feedback that is offered. There are some images below of examples of the different types of questions being asked.
Multiple Choice Question:
Entry Box Question:
Sentence Re-Ordering Question: enter image description here
Matching Question:
Example of a question answered incorrectly: enter image description here
Example of a question answered correctly: enter image description here
-
\$\begingroup\$ As far as I know the general rule is "avoid global variables". Only used them for constants. If parts of your code require accessing common variables, then all those functions and variables should belong to a class. \$\endgroup\$eric.m– eric.m2019年09月02日 16:03:09 +00:00Commented Sep 2, 2019 at 16:03
2 Answers 2
It's clear that you're starting to pick up some good habits, but this code still has a way to go.
This is a comment
Module Docstring:
No need to write this - it's obvious from context.
Type hints
def __init__(self, sf, cho, noa):
Those parameters have no documentation, and it isn't clear what type they are. Do a Google for type hinting in Python - that will help, even if you don't end up adding a docstring for this function.
Class purpose
class CheckAnswers:
The name of this class on its own suggests that it isn't really being used correctly. "Check answers", as an action phrase, means that this would be better-suited to a method. At the least, this should be called AnswerCheck
or AnswerChecker
, nouns that imply objects instead of actions.
Dictionaries
This block:
if lesson_type == "quiz":
no_of_quiz_questions = 8
if lesson_type == "short":
no_of_quiz_questions = 4
if lesson_type == "conversation":
no_of_quiz_questions = 1
if lesson_type == "long":
no_of_quiz_questions = 8
is better-represented as a dictionary with a single lookup call.
Globals and state management
This boils down to object-oriented programming theory and how to manage program state. You're correct to identify that your usage of globals is not ideal. Taking a look at this code:
global WORD_POSITION
if question_directory["translationdirection"] == "EtoA":
word_chosen[i].grid(column=size_of_word_bank-WORD_POSITION, row=1, columnspan=1)
if question_directory["translationdirection"] == "AtoE":
word_chosen[i].grid(column=WORD_POSITION+1, row=1, columnspan=1)
WORD_POSITION += 1
global USER_ANSWER
USER_ANSWER.append(sentance_components[i])
You're modifying two globals. That means that this method is not written in the right context. It should probably be written as a method on a top-level Game
class, and those two variables should be properties. Most of the time, class methods should only modify the state of their own class instance.
Nested functions
You've written a long series of nested functions in lesson_template
. There's no clear need for this. They should be moved to global scope, or to class methods.
Loooooooong methods
question_frame_populator
is way too long. Try to divide it up into logical sub-routintes.
Method names
sentancebuilder
should be sentence_builder
, or more appropriately build_sentence
.
else
if solution == USER_ANSWER:
CheckAnswers(special_frames, None, None).right_answer()
if solution != USER_ANSWER:
CheckAnswers(special_frames, None, None).wrong_answer()
You can replace the second if
with an else
. This pattern is seen elsewhere.
Your global variable naming violates PEP 8. Names in all upper-case are constants; variables that never change in value. Your globals aren't constant though. Lines like
LIVES -= 1
change the value that LIVES
holds.
Yes, global names should be in uppercase, but only because globals should also ideally only be constants. Global mutable state is a pain to deal with and complicates testing and debugging.
The simplest way to get rid of the global variables is to package them into a state that gets passed around to any function that needs it. While simple and not ideal, this solves the major problem with using globals: You can't just pass in data that you want to be used when testing. When using globals, you must modify the global state just to test how a function reacts to some data. This is less-straightforward than just passing the data, and has the potential to lead to situations where another function reads from the global that you set, causing it to change in behavior along with the function that you're testing.
So, how can you do this?
Represent the state of the game as a class (something like a dataclass
would work well here, but I'm just going to use a plain class for simplicity).
class GameState:
def __init__(self):
self.lives = 3
self.score = 0
self.var = None # Bad name. Doesn't describe its purpose
self.word_position = None
self.user_answer = None
Then pass an instance of this object (and alter this object) to change the "globals". This at the very least allows you to easily pass in exactly the data that you want to test.
I'd review further, but I started feeling sick about half way through writing this. I'm going to go lay down :/
-
\$\begingroup\$ Thanks, this was a really helpful review and I've been working on fixing my use of global variables as a result! I'm marking the other answer as "correct" simply because it is more comprehensive but this was truly helpful. Hope you're feeling better now. \$\endgroup\$Jack Duane– Jack Duane2019年09月03日 09:01:07 +00:00Commented Sep 3, 2019 at 9:01
-
\$\begingroup\$ @JackDuane No problem. Glad to help. And it ended up being food poisoning. Seems to have resolved itself now. \$\endgroup\$Carcigenicate– Carcigenicate2019年09月03日 13:04:05 +00:00Commented Sep 3, 2019 at 13:04
Explore related questions
See similar questions with these tags.