19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -199,53 +199,53 @@ state_gt_sol,gt,"Sololá","SOL"
state_gt_suc,gt,"Suchitepéquez","SUC"
state_gt_tot,gt,"Totonicapán","TOT"
state_gt_zac,gt,"Zacapa","ZAC"
state_jp_jp-23,jp,"Aichi","23"
state_jp_jp-05,jp,"Akita","05"
state_jp_jp-02,jp,"Aomori","02"
state_jp_jp-12,jp,"Chiba","12"
state_jp_jp-38,jp,"Ehime","38"
state_jp_jp-18,jp,"Fukui","18"
state_jp_jp-40,jp,"Fukuoka","40"
state_jp_jp-07,jp,"Fukushima","07"
state_jp_jp-21,jp,"Gifu","21"
state_jp_jp-10,jp,"Gunma","10"
state_jp_jp-34,jp,"Hiroshima","34"
state_jp_jp-01,jp,"Hokkaido","01"
state_jp_jp-28,jp,"Hyogo","28"
state_jp_jp-08,jp,"Ibaraki","08"
state_jp_jp-17,jp,"Ishikawa","17"
state_jp_jp-03,jp,"Iwate","03"
state_jp_jp-37,jp,"Kagawa","37"
state_jp_jp-46,jp,"Kagoshima","46"
state_jp_jp-14,jp,"Kanagawa","14"
state_jp_jp-39,jp,"Kochi","39"
state_jp_jp-43,jp,"Kumamoto","43"
state_jp_jp-26,jp,"Kyoto","26"
state_jp_jp-24,jp,"Mie","24"
state_jp_jp-04,jp,"Miyagi","04"
state_jp_jp-45,jp,"Miyazaki","45"
state_jp_jp-20,jp,"Nagano","20"
state_jp_jp-42,jp,"Nagasaki","42"
state_jp_jp-29,jp,"Nara","29"
state_jp_jp-15,jp,"Niigata","15"
state_jp_jp-44,jp,"Oita","44"
state_jp_jp-33,jp,"Okayama","33"
state_jp_jp-47,jp,"Okinawa","47"
state_jp_jp-27,jp,"Osaka","27"
state_jp_jp-41,jp,"Saga","41"
state_jp_jp-11,jp,"Saitama","11"
state_jp_jp-25,jp,"Shiga","25"
state_jp_jp-32,jp,"Shimane","32"
state_jp_jp-22,jp,"Shizuoka","22"
state_jp_jp-09,jp,"Tochigi","09"
state_jp_jp-36,jp,"Tokushima","36"
state_jp_jp-31,jp,"Tottori","31"
state_jp_jp-16,jp,"Toyama","16"
state_jp_jp-13,jp,"Tokyo","13"
state_jp_jp-30,jp,"Wakayama","30"
state_jp_jp-06,jp,"Yamagata","06"
state_jp_jp-35,jp,"Yamaguchi","35"
state_jp_jp-19,jp,"Yamanashi","19"
state_jp_jp-23,jp,"愛知県","JP-23"
state_jp_jp-05,jp,"秋田県","JP-05"
state_jp_jp-02,jp,"青森県","JP-02"
state_jp_jp-12,jp,"千葉県","JP-12"
state_jp_jp-38,jp,"愛媛県","JP-38"
state_jp_jp-18,jp,"福井県","JP-18"
state_jp_jp-40,jp,"福岡県","JP-40"
state_jp_jp-07,jp,"福島県","JP-07"
state_jp_jp-21,jp,"岐阜県","JP-21"
state_jp_jp-10,jp,"群馬県","JP-10"
state_jp_jp-34,jp,"広島県","JP-34"
state_jp_jp-01,jp,"北海道","JP-01"
state_jp_jp-28,jp,"兵庫県","JP-28"
state_jp_jp-08,jp,"茨城県","JP-08"
state_jp_jp-17,jp,"石川県","JP-17"
state_jp_jp-03,jp,"岩手県","JP-03"
state_jp_jp-37,jp,"香川県","JP-37"
state_jp_jp-46,jp,"鹿児島県","JP-46"
state_jp_jp-14,jp,"神奈川県","JP-14"
state_jp_jp-39,jp,"高知県","JP-39"
state_jp_jp-43,jp,"熊本県","JP-43"
state_jp_jp-26,jp,"京都府","JP-26"
state_jp_jp-24,jp,"三重県","JP-24"
state_jp_jp-04,jp,"宮城県","JP-04"
state_jp_jp-45,jp,"宮崎県","JP-45"
state_jp_jp-20,jp,"長野県","JP-20"
state_jp_jp-42,jp,"長崎県","JP-42"
state_jp_jp-29,jp,"奈良県","JP-29"
state_jp_jp-15,jp,"新潟県","JP-15"
state_jp_jp-44,jp,"大分県","JP-44"
state_jp_jp-33,jp,"岡山県","JP-33"
state_jp_jp-47,jp,"沖縄県","JP-47"
state_jp_jp-27,jp,"大阪府","JP-27"
state_jp_jp-41,jp,"佐賀県","JP-41"
state_jp_jp-11,jp,"埼玉県","JP-11"
state_jp_jp-25,jp,"滋賀県","JP-25"
state_jp_jp-32,jp,"島根県","JP-32"
state_jp_jp-22,jp,"静岡県","JP-22"
state_jp_jp-09,jp,"栃木県","JP-09"
state_jp_jp-36,jp,"徳島県","JP-36"
state_jp_jp-31,jp,"鳥取県","JP-31"
state_jp_jp-16,jp,"富山県","JP-16"
state_jp_jp-13,jp,"東京都","JP-13"
state_jp_jp-30,jp,"和歌山県","JP-30"
state_jp_jp-06,jp,"山形県","JP-06"
state_jp_jp-35,jp,"山口県","JP-35"
state_jp_jp-19,jp,"山梨県","JP-19"
state_pt_pt-01,pt,"Aveiro","01"
state_pt_pt-02,pt,"Beja","02"
state_pt_pt-03,pt,"Braga","03"
@ -600,7 +600,7 @@ state_in_mn,in,"Manipur","MN"
state_in_ml,in,"Meghalaya","ML"
state_in_mz,in,"Mizoram","MZ"
state_in_nl,in,"Nagaland","NL"
state_in_or,in,"Odisha","OR"
state_in_or,in,"Odisha","OD"
state_in_py,in,"Puducherry","PY"
state_in_pb,in,"Punjab","PB"
state_in_rj,in,"Rajasthan","RJ"
@ -1144,22 +1144,22 @@ state_pe_22,pe,"San Martín","22"
state_pe_23,pe,"Tacna","23"
state_pe_24,pe,"Tumbes","24"
state_pe_25,pe,"Ucayali","25"
state_cl_01,cl,"Tarapacá","01"
state_cl_02,cl,"Antofagasta","02"
state_cl_03,cl,"Atacama","03"
state_cl_04,cl,"Coquimbo","04"
state_cl_05,cl,"Valparaíso","05"
state_cl_06,cl,"del Libertador Gral. Bernardo O'Higgins","06"
state_cl_07,cl,"del Maule","07"
state_cl_08,cl,"del BíoBio","08"
state_cl_09,cl,"de la Araucania","09"
state_cl_10,cl,"de los Lagos","10"
state_cl_11,cl,"Aysén del Gral. Carlos Ibáñez del Campo","11"
state_cl_12,cl,"Magallanes","12"
state_cl_13,cl,"Metropolitana","13"
state_cl_14,cl,"Los Ríos","14"
state_cl_15,cl,"Arica y Parinacota","15"
state_cl_16,cl,"del Ñuble","16"
state_cl_01,cl,"Tarapacá","CL-TA"
state_cl_02,cl,"Antofagasta","CL-AN"
state_cl_03,cl,"Atacama","CL-AT"
state_cl_04,cl,"Coquimbo","CL-CO"
state_cl_05,cl,"Valparaíso","CL-VS"
state_cl_06,cl,"del Libertador Gral. Bernardo O'Higgins","CL-LI"
state_cl_07,cl,"del Maule","CL-ML"
state_cl_08,cl,"del BíoBio","CL-BI"
state_cl_09,cl,"de la Araucania","CL-AR"
state_cl_10,cl,"de los Lagos","CL-LL"
state_cl_11,cl,"Aysén del Gral. Carlos Ibáñez del Campo","CL-AI"
state_cl_12,cl,"Magallanes","CL-MA"
state_cl_13,cl,"Metropolitana","CL-RM"
state_cl_14,cl,"Los Ríos","CL-LR"
state_cl_15,cl,"Arica y Parinacota","CL-AP"
state_cl_16,cl,"del Ñuble","CL-NB"
state_ee_37,ee,"Harjumaa","EE-37"
state_ee_39,ee,"Hiiumaa","EE-39"
state_ee_44,ee,"Ida-Virumaa","EE-44"
@ -1468,83 +1468,83 @@ state_ch_ge_it,ch,"Ginevra","GE-IT"
state_ch_ge_fr,ch,"Genève","GE-FR"
state_ch_ju,ch,"Jura","JU"
state_ch_ju_it,ch,"Giura","JU-IT"
state_th_001,base.th,"Bangkok","TH-10"
state_th_002,base.th,"Amnat Charoen","TH-37"
state_th_003,base.th,"Ang Thong","TH-15"
state_th_004,base.th,"Bueng Kan","TH-38"
state_th_005,base.th,"Buriram","TH-31"
state_th_006,base.th,"Chachoengsao","TH-24"
state_th_007,base.th,"Chai Nat","TH-18"
state_th_008,base.th,"Chaiyaphum","TH-36"
state_th_009,base.th,"Chanthaburi","TH-22"
state_th_010,base.th,"Chiang Mai","TH-50"
state_th_011,base.th,"Chiang Rai","TH-57"
state_th_012,base.th,"Chonburi","TH-20"
state_th_013,base.th,"Chumphon","TH-86"
state_th_014,base.th,"Kalasin","TH-46"
state_th_015,base.th,"Kamphaeng Phet","TH-62"
state_th_016,base.th,"Kanchanaburi","TH-71"
state_th_017,base.th,"Khon Kaen","TH-40"
state_th_018,base.th,"Krabi","TH-81"
state_th_019,base.th,"Lampang","TH-52"
state_th_020,base.th,"Lamphun","TH-51"
state_th_021,base.th,"Loei","TH-42"
state_th_022,base.th,"Lopburi","TH-16"
state_th_023,base.th,"Mae Hong Son","TH-58"
state_th_024,base.th,"Maha Sarakham","TH-44"
state_th_025,base.th,"Mukdahan","TH-49"
state_th_026,base.th,"Nakhon Nayok","TH-26"
state_th_027,base.th,"Nakhon Pathom","TH-73"
state_th_028,base.th,"Nakhon Phanom","TH-48"
state_th_029,base.th,"Nakhon Ratchasima","TH-30"
state_th_030,base.th,"Nakhon Sawan","TH-60"
state_th_031,base.th,"Nakhon Si Thammarat","TH-80"
state_th_032,base.th,"Nan","TH-55"
state_th_033,base.th,"Narathiwat","TH-96"
state_th_034,base.th,"Nong Bua Lamphu","TH-39"
state_th_035,base.th,"Nong Khai","TH-43"
state_th_036,base.th,"Nonthaburi","TH-12"
state_th_037,base.th,"Pathum Thani","TH-13"
state_th_038,base.th,"Pattani","TH-94"
state_th_039,base.th,"Phang Nga","TH-82"
state_th_040,base.th,"Phatthalung","TH-93"
state_th_041,base.th,"Phayao","TH-56"
state_th_042,base.th,"Phetchabun","TH-67"
state_th_043,base.th,"Phetchaburi","TH-76"
state_th_044,base.th,"Phichit","TH-66"
state_th_045,base.th,"Phitsanulok","TH-65"
state_th_046,base.th,"Phra Nakhon Si Ayutthaya","TH-14"
state_th_047,base.th,"Phrae","TH-54"
state_th_048,base.th,"Phuket","TH-83"
state_th_049,base.th,"Prachinburi","TH-25"
state_th_050,base.th,"Prachuap Khiri Khan","TH-77"
state_th_051,base.th,"Ranong","TH-85"
state_th_052,base.th,"Ratchaburi","TH-70"
state_th_053,base.th,"Rayong","TH-21"
state_th_054,base.th,"Roi Et","TH-45"
state_th_055,base.th,"Sa Kaeo","TH-27"
state_th_056,base.th,"Sakon Nakhon","TH-47"
state_th_057,base.th,"Samut Prakan","TH-11"
state_th_058,base.th,"Samut Sakhon","TH-74"
state_th_059,base.th,"Samut Songkhram","TH-75"
state_th_060,base.th,"Saraburi","TH-19"
state_th_061,base.th,"Satun","TH-91"
state_th_062,base.th,"Sing Buri","TH-17"
state_th_063,base.th,"Sisaket","TH-33"
state_th_064,base.th,"Songkhla","TH-90"
state_th_065,base.th,"Sukhothai","TH-64"
state_th_066,base.th,"Suphan Buri","TH-72"
state_th_067,base.th,"Surat Thani","TH-84"
state_th_068,base.th,"Surin","TH-32"
state_th_069,base.th,"Tak","TH-63"
state_th_070,base.th,"Trang","TH-92"
state_th_071,base.th,"Trat","TH-23"
state_th_072,base.th,"Ubon Ratchathani","TH-34"
state_th_073,base.th,"Udon Thani","TH-41"
state_th_074,base.th,"Uthai Thani","TH-61"
state_th_075,base.th,"Uttaradit","TH-53"
state_th_076,base.th,"Yala","TH-95"
state_th_077,base.th,"Yasothon","TH-35"
state_th_001,base.th,"กรุงเทพมหานคร","TH-10"
state_th_002,base.th,"อำนาจเจริญ","TH-37"
state_th_003,base.th,"อ่างทอง","TH-15"
state_th_004,base.th,"บึงกาฬ","TH-38"
state_th_005,base.th,"บุรีรัมย์","TH-31"
state_th_006,base.th,"ฉะเชิงเทรา","TH-24"
state_th_007,base.th,"ชัยนาท","TH-18"
state_th_008,base.th,"ชัยภูมิ","TH-36"
state_th_009,base.th,"จันทบุรี","TH-22"
state_th_010,base.th,"เชียงใหม่","TH-50"
state_th_011,base.th,"เชียงราย","TH-57"
state_th_012,base.th,"ชลบุรี","TH-20"
state_th_013,base.th,"ชุมพร","TH-86"
state_th_014,base.th,"กาฬสินธุ์","TH-46"
state_th_015,base.th,"กำแพงเพชร","TH-62"
state_th_016,base.th,"กาญจนบุรี","TH-71"
state_th_017,base.th,"ขอนแก่น","TH-40"
state_th_018,base.th,"กระบี่","TH-81"
state_th_019,base.th,"ลำปาง","TH-52"
state_th_020,base.th,"ลำพูน","TH-51"
state_th_021,base.th,"เลย","TH-42"
state_th_022,base.th,"ลพบุรี","TH-16"
state_th_023,base.th,"แม่ฮ่องสอน","TH-58"
state_th_024,base.th,"มหาสารคาม","TH-44"
state_th_025,base.th,"มุกดาหาร","TH-49"
state_th_026,base.th,"นครนายก","TH-26"
state_th_027,base.th,"นครปฐม","TH-73"
state_th_028,base.th,"นครพนม","TH-48"
state_th_029,base.th,"นครราชสีมา","TH-30"
state_th_030,base.th,"นครสวรรค์","TH-60"
state_th_031,base.th,"นครศรีธรรมราช","TH-80"
state_th_032,base.th,"น่าน","TH-55"
state_th_033,base.th,"นราธิวาส","TH-96"
state_th_034,base.th,"หนองบัวลำภู","TH-39"
state_th_035,base.th,"หนองคาย","TH-43"
state_th_036,base.th,"นนทบุรี","TH-12"
state_th_037,base.th,"ปทุมธานี","TH-13"
state_th_038,base.th,"ปัตตานี","TH-94"
state_th_039,base.th,"พังงา","TH-82"
state_th_040,base.th,"พัทลุง","TH-93"
state_th_041,base.th,"พะเยา","TH-56"
state_th_042,base.th,"เพชรบูรณ์","TH-67"
state_th_043,base.th,"เพชรบุรี","TH-76"
state_th_044,base.th,"พิจิตร","TH-66"
state_th_045,base.th,"พิษณุโลก","TH-65"
state_th_046,base.th,"พระนครศรีอยุธยา","TH-14"
state_th_047,base.th,"แพร่","TH-54"
state_th_048,base.th,"ภูเก็ต","TH-83"
state_th_049,base.th,"ปราจีนบุรี","TH-25"
state_th_050,base.th,"ประจวบคีรีขันธ์","TH-77"
state_th_051,base.th,"ระนอง","TH-85"
state_th_052,base.th,"ราชบุรี","TH-70"
state_th_053,base.th,"ระยอง","TH-21"
state_th_054,base.th,"ร้อยเอ็ด","TH-45"
state_th_055,base.th,"สระแก้ว","TH-27"
state_th_056,base.th,"สกลนคร","TH-47"
state_th_057,base.th,"สมุทรปราการ","TH-11"
state_th_058,base.th,"สมุทรสาคร","TH-74"
state_th_059,base.th,"สมุทรสงคราม","TH-75"
state_th_060,base.th,"สระบุรี","TH-19"
state_th_061,base.th,"สตูล","TH-91"
state_th_062,base.th,"สิงห์บุรี","TH-17"
state_th_063,base.th,"ศรีสะเกษ","TH-33"
state_th_064,base.th,"สงขลา","TH-90"
state_th_065,base.th,"สุโขทัย","TH-64"
state_th_066,base.th,"สุพรรณบุรี","TH-72"
state_th_067,base.th,"สุราษฎร์ธานี","TH-84"
state_th_068,base.th,"สุรินทร์","TH-32"
state_th_069,base.th,"ตาก","TH-63"
state_th_070,base.th,"ตรัง","TH-92"
state_th_071,base.th,"ตราด","TH-23"
state_th_072,base.th,"อุบลราชธานี","TH-34"
state_th_073,base.th,"อุดรธานี","TH-41"
state_th_074,base.th,"อุทัยธานี","TH-61"
state_th_075,base.th,"อุตรดิตถ์","TH-53"
state_th_076,base.th,"ยะลา","TH-95"
state_th_077,base.th,"ยโสธร","TH-35"
state_sa_1,sa,"Abha","AHB"
state_sa_2,sa,"Abqaiq","ABQ"
state_sa_3,sa,"Ad Dammam","DMM"
@ -1893,3 +1893,86 @@ state_bn_b,bn,"Brunei-Muara","B"
state_bn_k,bn,"Belait","K"
state_bn_t,bn,"Tutong","T"
state_bn_p,bn,"Temburong","P"
state_ph_01,ph,"National Capital Region","PH-00"
state_ph_02,ph,"Abra","PH-ABR"
state_ph_03,ph,"Agusan del Norte","PH-AGN"
state_ph_04,ph,"Agusan del Sur","PH-AGS"
state_ph_05,ph,"Aklan","PH-AKL"
state_ph_06,ph,"Albay","PH-ALB"
state_ph_07,ph,"Antique","PH-ANT"
state_ph_08,ph,"Apayao","PH-APA"
state_ph_09,ph,"Aurora","PH-AUR"
state_ph_10,ph,"Basilan","PH-BAS"
state_ph_11,ph,"Bataan","PH-BAN"
state_ph_12,ph,"Batanes","PH-BTN"
state_ph_13,ph,"Batangas","PH-BTG"
state_ph_14,ph,"Benguet","PH-BEN"
state_ph_15,ph,"Biliran","PH-BIL"
state_ph_16,ph,"Bohol","PH-BOH"
state_ph_17,ph,"Bukidnon","PH-BUK"
state_ph_18,ph,"Bulacan","PH-BUL"
state_ph_19,ph,"Cagayan","PH-CAG"
state_ph_20,ph,"Camarines Norte","PH-CAN"
state_ph_21,ph,"Camarines Sur","PH-CAS"
state_ph_22,ph,"Camiguin","PH-CAM"
state_ph_23,ph,"Capiz","PH-CAP"
state_ph_24,ph,"Catanduanes","PH-CAT"
state_ph_25,ph,"Cavite","PH-CAV"
state_ph_26,ph,"Cebu","PH-CEB"
state_ph_27,ph,"Cotabato","PH-NCO"
state_ph_28,ph,"Davao Occidental","PH-DVO"
state_ph_29,ph,"Davao Oriental","PH-DAO"
state_ph_30,ph,"Davao de Oro","PH-COM"
state_ph_31,ph,"Davao del Norte","PH-DAV"
state_ph_32,ph,"Davao del Sur","PH-DAS"
state_ph_33,ph,"Dinagat Islands","PH-DIN"
state_ph_34,ph,"Eastern Samar","PH-EAS"
state_ph_35,ph,"Guimaras","PH-GUI"
state_ph_36,ph,"Ifugao","PH-IFU"
state_ph_37,ph,"Ilocos Norte","PH-ILN"
state_ph_38,ph,"Ilocos Sur","PH-ILS"
state_ph_39,ph,"Iloilo","PH-ILI"
state_ph_40,ph,"Isabela","PH-ISA"
state_ph_41,ph,"Kalinga","PH-KAL"
state_ph_42,ph,"La Union","PH-LUN"
state_ph_43,ph,"Laguna","PH-LAG"
state_ph_44,ph,"Lanao del Norte","PH-LAN"
state_ph_45,ph,"Lanao del Sur","PH-LAS"
state_ph_46,ph,"Leyte","PH-LEY"
state_ph_47,ph,"Maguindanao del Norte","PH-MGN"
state_ph_48,ph,"Maguindanao del Sur","PH-MGS"
state_ph_49,ph,"Marinduque","PH-MAD"
state_ph_50,ph,"Masbate","PH-MAS"
state_ph_51,ph,"Mindoro Occidental","PH-MDC"
state_ph_52,ph,"Mindoro Oriental","PH-MDR"
state_ph_53,ph,"Misamis Occidental","PH-MSC"
state_ph_54,ph,"Misamis Oriental","PH-MSR"
state_ph_55,ph,"Mountain Province","PH-MOU"
state_ph_56,ph,"Negros Occidental","PH-NEC"
state_ph_57,ph,"Negros Oriental","PH-NER"
state_ph_58,ph,"Northern Samar","PH-NSA"
state_ph_59,ph,"Nueva Ecija","PH-NUE"
state_ph_60,ph,"Nueva Vizcaya","PH-NUV"
state_ph_61,ph,"Palawan","PH-PLW"
state_ph_62,ph,"Pampanga","PH-PAM"
state_ph_63,ph,"Pangasinan","PH-PAN"
state_ph_64,ph,"Quezon","PH-QUE"
state_ph_65,ph,"Quirino","PH-QUI"
state_ph_66,ph,"Rizal","PH-RIZ"
state_ph_67,ph,"Romblon","PH-ROM"
state_ph_68,ph,"Samar","PH-WSA"
state_ph_69,ph,"Sarangani","PH-SAR"
state_ph_70,ph,"Siquijor","PH-SIG"
state_ph_71,ph,"Sorsogon","PH-SOR"
state_ph_72,ph,"South Cotabato","PH-SCO"
state_ph_73,ph,"Southern Leyte","PH-SLE"
state_ph_74,ph,"Sultan Kudarat","PH-SUK"
state_ph_75,ph,"Sulu","PH-SLU"
state_ph_76,ph,"Surigao del Norte","PH-SUN"
state_ph_77,ph,"Surigao del Sur","PH-SUR"
state_ph_78,ph,"Tarlac","PH-TAR"
state_ph_79,ph,"Tawi-Tawi","PH-TAW"
state_ph_80,ph,"Zambales","PH-ZMB"
state_ph_81,ph,"Zamboanga Sibugay","PH-ZSI"
state_ph_82,ph,"Zamboanga del Norte","PH-ZAN"
state_ph_83,ph,"Zamboanga del Sur","PH-ZAS"

1 id country_id:id name code
199 state_gt_suc gt Suchitepéquez SUC
200 state_gt_tot gt Totonicapán TOT
201 state_gt_zac gt Zacapa ZAC
202 state_jp_jp-23 jp Aichi 愛知県 23 JP-23
203 state_jp_jp-05 jp Akita 秋田県 05 JP-05
204 state_jp_jp-02 jp Aomori 青森県 02 JP-02
205 state_jp_jp-12 jp Chiba 千葉県 12 JP-12
206 state_jp_jp-38 jp Ehime 愛媛県 38 JP-38
207 state_jp_jp-18 jp Fukui 福井県 18 JP-18
208 state_jp_jp-40 jp Fukuoka 福岡県 40 JP-40
209 state_jp_jp-07 jp Fukushima 福島県 07 JP-07
210 state_jp_jp-21 jp Gifu 岐阜県 21 JP-21
211 state_jp_jp-10 jp Gunma 群馬県 10 JP-10
212 state_jp_jp-34 jp Hiroshima 広島県 34 JP-34
213 state_jp_jp-01 jp Hokkaido 北海道 01 JP-01
214 state_jp_jp-28 jp Hyogo 兵庫県 28 JP-28
215 state_jp_jp-08 jp Ibaraki 茨城県 08 JP-08
216 state_jp_jp-17 jp Ishikawa 石川県 17 JP-17
217 state_jp_jp-03 jp Iwate 岩手県 03 JP-03
218 state_jp_jp-37 jp Kagawa 香川県 37 JP-37
219 state_jp_jp-46 jp Kagoshima 鹿児島県 46 JP-46
220 state_jp_jp-14 jp Kanagawa 神奈川県 14 JP-14
221 state_jp_jp-39 jp Kochi 高知県 39 JP-39
222 state_jp_jp-43 jp Kumamoto 熊本県 43 JP-43
223 state_jp_jp-26 jp Kyoto 京都府 26 JP-26
224 state_jp_jp-24 jp Mie 三重県 24 JP-24
225 state_jp_jp-04 jp Miyagi 宮城県 04 JP-04
226 state_jp_jp-45 jp Miyazaki 宮崎県 45 JP-45
227 state_jp_jp-20 jp Nagano 長野県 20 JP-20
228 state_jp_jp-42 jp Nagasaki 長崎県 42 JP-42
229 state_jp_jp-29 jp Nara 奈良県 29 JP-29
230 state_jp_jp-15 jp Niigata 新潟県 15 JP-15
231 state_jp_jp-44 jp Oita 大分県 44 JP-44
232 state_jp_jp-33 jp Okayama 岡山県 33 JP-33
233 state_jp_jp-47 jp Okinawa 沖縄県 47 JP-47
234 state_jp_jp-27 jp Osaka 大阪府 27 JP-27
235 state_jp_jp-41 jp Saga 佐賀県 41 JP-41
236 state_jp_jp-11 jp Saitama 埼玉県 11 JP-11
237 state_jp_jp-25 jp Shiga 滋賀県 25 JP-25
238 state_jp_jp-32 jp Shimane 島根県 32 JP-32
239 state_jp_jp-22 jp Shizuoka 静岡県 22 JP-22
240 state_jp_jp-09 jp Tochigi 栃木県 09 JP-09
241 state_jp_jp-36 jp Tokushima 徳島県 36 JP-36
242 state_jp_jp-31 jp Tottori 鳥取県 31 JP-31
243 state_jp_jp-16 jp Toyama 富山県 16 JP-16
244 state_jp_jp-13 jp Tokyo 東京都 13 JP-13
245 state_jp_jp-30 jp Wakayama 和歌山県 30 JP-30
246 state_jp_jp-06 jp Yamagata 山形県 06 JP-06
247 state_jp_jp-35 jp Yamaguchi 山口県 35 JP-35
248 state_jp_jp-19 jp Yamanashi 山梨県 19 JP-19
249 state_pt_pt-01 pt Aveiro 01
250 state_pt_pt-02 pt Beja 02
251 state_pt_pt-03 pt Braga 03
600 state_in_ml in Meghalaya ML
601 state_in_mz in Mizoram MZ
602 state_in_nl in Nagaland NL
603 state_in_or in Odisha OR OD
604 state_in_py in Puducherry PY
605 state_in_pb in Punjab PB
606 state_in_rj in Rajasthan RJ
1144 state_pe_23 pe Tacna 23
1145 state_pe_24 pe Tumbes 24
1146 state_pe_25 pe Ucayali 25
1147 state_cl_01 cl Tarapacá 01 CL-TA
1148 state_cl_02 cl Antofagasta 02 CL-AN
1149 state_cl_03 cl Atacama 03 CL-AT
1150 state_cl_04 cl Coquimbo 04 CL-CO
1151 state_cl_05 cl Valparaíso 05 CL-VS
1152 state_cl_06 cl del Libertador Gral. Bernardo O'Higgins 06 CL-LI
1153 state_cl_07 cl del Maule 07 CL-ML
1154 state_cl_08 cl del BíoBio 08 CL-BI
1155 state_cl_09 cl de la Araucania 09 CL-AR
1156 state_cl_10 cl de los Lagos 10 CL-LL
1157 state_cl_11 cl Aysén del Gral. Carlos Ibáñez del Campo 11 CL-AI
1158 state_cl_12 cl Magallanes 12 CL-MA
1159 state_cl_13 cl Metropolitana 13 CL-RM
1160 state_cl_14 cl Los Ríos 14 CL-LR
1161 state_cl_15 cl Arica y Parinacota 15 CL-AP
1162 state_cl_16 cl del Ñuble 16 CL-NB
1163 state_ee_37 ee Harjumaa EE-37
1164 state_ee_39 ee Hiiumaa EE-39
1165 state_ee_44 ee Ida-Virumaa EE-44
1468 state_ch_ge_fr ch Genève GE-FR
1469 state_ch_ju ch Jura JU
1470 state_ch_ju_it ch Giura JU-IT
1471 state_th_001 base.th Bangkok กรุงเทพมหานคร TH-10
1472 state_th_002 base.th Amnat Charoen อำนาจเจริญ TH-37
1473 state_th_003 base.th Ang Thong อ่างทอง TH-15
1474 state_th_004 base.th Bueng Kan บึงกาฬ TH-38
1475 state_th_005 base.th Buriram บุรีรัมย์ TH-31
1476 state_th_006 base.th Chachoengsao ฉะเชิงเทรา TH-24
1477 state_th_007 base.th Chai Nat ชัยนาท TH-18
1478 state_th_008 base.th Chaiyaphum ชัยภูมิ TH-36
1479 state_th_009 base.th Chanthaburi จันทบุรี TH-22
1480 state_th_010 base.th Chiang Mai เชียงใหม่ TH-50
1481 state_th_011 base.th Chiang Rai เชียงราย TH-57
1482 state_th_012 base.th Chonburi ชลบุรี TH-20
1483 state_th_013 base.th Chumphon ชุมพร TH-86
1484 state_th_014 base.th Kalasin กาฬสินธุ์ TH-46
1485 state_th_015 base.th Kamphaeng Phet กำแพงเพชร TH-62
1486 state_th_016 base.th Kanchanaburi กาญจนบุรี TH-71
1487 state_th_017 base.th Khon Kaen ขอนแก่น TH-40
1488 state_th_018 base.th Krabi กระบี่ TH-81
1489 state_th_019 base.th Lampang ลำปาง TH-52
1490 state_th_020 base.th Lamphun ลำพูน TH-51
1491 state_th_021 base.th Loei เลย TH-42
1492 state_th_022 base.th Lopburi ลพบุรี TH-16
1493 state_th_023 base.th Mae Hong Son แม่ฮ่องสอน TH-58
1494 state_th_024 base.th Maha Sarakham มหาสารคาม TH-44
1495 state_th_025 base.th Mukdahan มุกดาหาร TH-49
1496 state_th_026 base.th Nakhon Nayok นครนายก TH-26
1497 state_th_027 base.th Nakhon Pathom นครปฐม TH-73
1498 state_th_028 base.th Nakhon Phanom นครพนม TH-48
1499 state_th_029 base.th Nakhon Ratchasima นครราชสีมา TH-30
1500 state_th_030 base.th Nakhon Sawan นครสวรรค์ TH-60
1501 state_th_031 base.th Nakhon Si Thammarat นครศรีธรรมราช TH-80
1502 state_th_032 base.th Nan น่าน TH-55
1503 state_th_033 base.th Narathiwat นราธิวาส TH-96
1504 state_th_034 base.th Nong Bua Lamphu หนองบัวลำภู TH-39
1505 state_th_035 base.th Nong Khai หนองคาย TH-43
1506 state_th_036 base.th Nonthaburi นนทบุรี TH-12
1507 state_th_037 base.th Pathum Thani ปทุมธานี TH-13
1508 state_th_038 base.th Pattani ปัตตานี TH-94
1509 state_th_039 base.th Phang Nga พังงา TH-82
1510 state_th_040 base.th Phatthalung พัทลุง TH-93
1511 state_th_041 base.th Phayao พะเยา TH-56
1512 state_th_042 base.th Phetchabun เพชรบูรณ์ TH-67
1513 state_th_043 base.th Phetchaburi เพชรบุรี TH-76
1514 state_th_044 base.th Phichit พิจิตร TH-66
1515 state_th_045 base.th Phitsanulok พิษณุโลก TH-65
1516 state_th_046 base.th Phra Nakhon Si Ayutthaya พระนครศรีอยุธยา TH-14
1517 state_th_047 base.th Phrae แพร่ TH-54
1518 state_th_048 base.th Phuket ภูเก็ต TH-83
1519 state_th_049 base.th Prachinburi ปราจีนบุรี TH-25
1520 state_th_050 base.th Prachuap Khiri Khan ประจวบคีรีขันธ์ TH-77
1521 state_th_051 base.th Ranong ระนอง TH-85
1522 state_th_052 base.th Ratchaburi ราชบุรี TH-70
1523 state_th_053 base.th Rayong ระยอง TH-21
1524 state_th_054 base.th Roi Et ร้อยเอ็ด TH-45
1525 state_th_055 base.th Sa Kaeo สระแก้ว TH-27
1526 state_th_056 base.th Sakon Nakhon สกลนคร TH-47
1527 state_th_057 base.th Samut Prakan สมุทรปราการ TH-11
1528 state_th_058 base.th Samut Sakhon สมุทรสาคร TH-74
1529 state_th_059 base.th Samut Songkhram สมุทรสงคราม TH-75
1530 state_th_060 base.th Saraburi สระบุรี TH-19
1531 state_th_061 base.th Satun สตูล TH-91
1532 state_th_062 base.th Sing Buri สิงห์บุรี TH-17
1533 state_th_063 base.th Sisaket ศรีสะเกษ TH-33
1534 state_th_064 base.th Songkhla สงขลา TH-90
1535 state_th_065 base.th Sukhothai สุโขทัย TH-64
1536 state_th_066 base.th Suphan Buri สุพรรณบุรี TH-72
1537 state_th_067 base.th Surat Thani สุราษฎร์ธานี TH-84
1538 state_th_068 base.th Surin สุรินทร์ TH-32
1539 state_th_069 base.th Tak ตาก TH-63
1540 state_th_070 base.th Trang ตรัง TH-92
1541 state_th_071 base.th Trat ตราด TH-23
1542 state_th_072 base.th Ubon Ratchathani อุบลราชธานี TH-34
1543 state_th_073 base.th Udon Thani อุดรธานี TH-41
1544 state_th_074 base.th Uthai Thani อุทัยธานี TH-61
1545 state_th_075 base.th Uttaradit อุตรดิตถ์ TH-53
1546 state_th_076 base.th Yala ยะลา TH-95
1547 state_th_077 base.th Yasothon ยโสธร TH-35
1548 state_sa_1 sa Abha AHB
1549 state_sa_2 sa Abqaiq ABQ
1550 state_sa_3 sa Ad Dammam DMM
1893 state_bn_k bn Belait K
1894 state_bn_t bn Tutong T
1895 state_bn_p bn Temburong P
1896 state_ph_01 ph National Capital Region PH-00
1897 state_ph_02 ph Abra PH-ABR
1898 state_ph_03 ph Agusan del Norte PH-AGN
1899 state_ph_04 ph Agusan del Sur PH-AGS
1900 state_ph_05 ph Aklan PH-AKL
1901 state_ph_06 ph Albay PH-ALB
1902 state_ph_07 ph Antique PH-ANT
1903 state_ph_08 ph Apayao PH-APA
1904 state_ph_09 ph Aurora PH-AUR
1905 state_ph_10 ph Basilan PH-BAS
1906 state_ph_11 ph Bataan PH-BAN
1907 state_ph_12 ph Batanes PH-BTN
1908 state_ph_13 ph Batangas PH-BTG
1909 state_ph_14 ph Benguet PH-BEN
1910 state_ph_15 ph Biliran PH-BIL
1911 state_ph_16 ph Bohol PH-BOH
1912 state_ph_17 ph Bukidnon PH-BUK
1913 state_ph_18 ph Bulacan PH-BUL
1914 state_ph_19 ph Cagayan PH-CAG
1915 state_ph_20 ph Camarines Norte PH-CAN
1916 state_ph_21 ph Camarines Sur PH-CAS
1917 state_ph_22 ph Camiguin PH-CAM
1918 state_ph_23 ph Capiz PH-CAP
1919 state_ph_24 ph Catanduanes PH-CAT
1920 state_ph_25 ph Cavite PH-CAV
1921 state_ph_26 ph Cebu PH-CEB
1922 state_ph_27 ph Cotabato PH-NCO
1923 state_ph_28 ph Davao Occidental PH-DVO
1924 state_ph_29 ph Davao Oriental PH-DAO
1925 state_ph_30 ph Davao de Oro PH-COM
1926 state_ph_31 ph Davao del Norte PH-DAV
1927 state_ph_32 ph Davao del Sur PH-DAS
1928 state_ph_33 ph Dinagat Islands PH-DIN
1929 state_ph_34 ph Eastern Samar PH-EAS
1930 state_ph_35 ph Guimaras PH-GUI
1931 state_ph_36 ph Ifugao PH-IFU
1932 state_ph_37 ph Ilocos Norte PH-ILN
1933 state_ph_38 ph Ilocos Sur PH-ILS
1934 state_ph_39 ph Iloilo PH-ILI
1935 state_ph_40 ph Isabela PH-ISA
1936 state_ph_41 ph Kalinga PH-KAL
1937 state_ph_42 ph La Union PH-LUN
1938 state_ph_43 ph Laguna PH-LAG
1939 state_ph_44 ph Lanao del Norte PH-LAN
1940 state_ph_45 ph Lanao del Sur PH-LAS
1941 state_ph_46 ph Leyte PH-LEY
1942 state_ph_47 ph Maguindanao del Norte PH-MGN
1943 state_ph_48 ph Maguindanao del Sur PH-MGS
1944 state_ph_49 ph Marinduque PH-MAD
1945 state_ph_50 ph Masbate PH-MAS
1946 state_ph_51 ph Mindoro Occidental PH-MDC
1947 state_ph_52 ph Mindoro Oriental PH-MDR
1948 state_ph_53 ph Misamis Occidental PH-MSC
1949 state_ph_54 ph Misamis Oriental PH-MSR
1950 state_ph_55 ph Mountain Province PH-MOU
1951 state_ph_56 ph Negros Occidental PH-NEC
1952 state_ph_57 ph Negros Oriental PH-NER
1953 state_ph_58 ph Northern Samar PH-NSA
1954 state_ph_59 ph Nueva Ecija PH-NUE
1955 state_ph_60 ph Nueva Vizcaya PH-NUV
1956 state_ph_61 ph Palawan PH-PLW
1957 state_ph_62 ph Pampanga PH-PAM
1958 state_ph_63 ph Pangasinan PH-PAN
1959 state_ph_64 ph Quezon PH-QUE
1960 state_ph_65 ph Quirino PH-QUI
1961 state_ph_66 ph Rizal PH-RIZ
1962 state_ph_67 ph Romblon PH-ROM
1963 state_ph_68 ph Samar PH-WSA
1964 state_ph_69 ph Sarangani PH-SAR
1965 state_ph_70 ph Siquijor PH-SIG
1966 state_ph_71 ph Sorsogon PH-SOR
1967 state_ph_72 ph South Cotabato PH-SCO
1968 state_ph_73 ph Southern Leyte PH-SLE
1969 state_ph_74 ph Sultan Kudarat PH-SUK
1970 state_ph_75 ph Sulu PH-SLU
1971 state_ph_76 ph Surigao del Norte PH-SUN
1972 state_ph_77 ph Surigao del Sur PH-SUR
1973 state_ph_78 ph Tarlac PH-TAR
1974 state_ph_79 ph Tawi-Tawi PH-TAW
1975 state_ph_80 ph Zambales PH-ZMB
1976 state_ph_81 ph Zamboanga Sibugay PH-ZSI
1977 state_ph_82 ph Zamboanga del Norte PH-ZAN
1978 state_ph_83 ph Zamboanga del Sur PH-ZAS

View file

@ -3,19 +3,19 @@
"base.lang_am_ET","Amharic / አምሃርኛ","am_ET","am_ET","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
"base.lang_ar","Arabic / الْعَرَبيّة","ar_001","ar","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
"base.lang_ar_SY","Arabic (Syria) / الْعَرَبيّة","ar_SY","ar_SY","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
"base.lang_az","Azerbaijani / Azərbaycanca","az_AZ","az","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_az","Azerbaijani / Azərbaycanca","az_AZ","az","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","1"
"base.lang_eu_ES","Basque / Euskara","eu_ES","eu_ES","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_be","Belarusian / Беларуская мова","be_BY","be","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_bn_IN","Bengali / বাংলা","bn_IN","bn_IN","Left-to-Right","[3,0]",",","","%d/%m/%Y","%I:%M:%S %p","1"
"base.lang_bs_BA","Bosnian / bosanski jezik","bs_BA","bs","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_bg","Bulgarian / български език","bg_BG","bg","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_bs_BA","Bosnian / bosanski jezik","bs_BA","bs","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
"base.lang_bg","Bulgarian / български език","bg_BG","bg","Left-to-Right","[3,0]",",","","%d.%m.%Y","%H:%M:%S","1"
"base.lang_ca_ES","Catalan / Català","ca_ES","ca_ES","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_zh_CN","Chinese, Simplified / 简体中文","zh_CN","zh_CN","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%H:%M:%S","7"
"base.lang_zh_HK","Chinese, Traditional (HK) / 繁體中文 (香港)","zh_HK","zh_HK","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%I:%M:%S %p","7"
"base.lang_zh_CN","Chinese, Simplified / 简体中文","zh_CN","zh_CN","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
"base.lang_zh_HK","Chinese, Traditional (HK) / 繁體中文 (香港)","zh_HK","zh_HK","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%I:%M:%S %p","7"
"base.lang_zh_TW","Chinese, Traditional (TW) / 繁體中文 (台灣)","zh_TW","zh_TW","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
"base.lang_hr","Croatian / hrvatski jezik","hr_HR","hr","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_cs_CZ","Czech / Čeština","cs_CZ","cs_CZ","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_da_DK","Danish / Dansk","da_DK","da_DK","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
"base.lang_hr","Croatian / hrvatski jezik","hr_HR","hr","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","1"
"base.lang_cs_CZ","Czech / Čeština","cs_CZ","cs_CZ","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_da_DK","Danish / Dansk","da_DK","da_DK","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","1"
"base.lang_nl_BE","Dutch (BE) / Nederlands (BE)","nl_BE","nl_BE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_nl","Dutch / Nederlands","nl_NL","nl","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
"base.lang_en_AU","English (AU)","en_AU","en_AU","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
@ -23,49 +23,49 @@
"base.lang_en_GB","English (UK)","en_GB","en_GB","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
"base.lang_en_IN","English (IN)","en_IN","en_IN","Left-to-Right","[3,2,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
"base.lang_en_NZ","English (NZ)","en_NZ","en_NZ","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
"base.lang_et_EE","Estonian / Eesti keel","et_EE","et","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_fi","Finnish / Suomi","fi_FI","fi","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_et_EE","Estonian / Eesti keel","et_EE","et","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_fi","Finnish / Suomi","fi_FI","fi","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_fr_BE","French (BE) / Français (BE)","fr_BE","fr_BE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_fr_CA","French (CA) / Français (CA)","fr_CA","fr_CA","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","7"
"base.lang_fr_CH","French (CH) / Français (CH)","fr_CH","fr_CH","Left-to-Right","[3,0]",".","'","%d/%m/%Y","%H:%M:%S","1"
"base.lang_fr_CH","French (CH) / Français (CH)","fr_CH","fr_CH","Left-to-Right","[3,0]",".","'","%d.%m.%Y","%H:%M:%S","1"
"base.lang_fr","French / Français","fr_FR","fr","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_gl_ES","Galician / Galego","gl_ES","gl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ka_GE","Georgian / ქართული ენა","ka_GE","ka","Left-to-Right","[3,0]",",",".","%m/%d/%Y","%H:%M:%S","1"
"base.lang_de","German / Deutsch","de_DE","de","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_de_CH","German (CH) / Deutsch (CH)","de_CH","de_CH","Left-to-Right","[3,0]",".","'","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ka_GE","Georgian / ქართული ენა","ka_GE","ka","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_de","German / Deutsch","de_DE","de","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","1"
"base.lang_de_CH","German (CH) / Deutsch (CH)","de_CH","de_CH","Left-to-Right","[3,0]",".","'","%d.%m.%Y","%H:%M:%S","1"
"base.lang_el_GR","Greek / Ελληνικά","el_GR","el_GR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%I:%M:%S %p","1"
"base.lang_gu_IN","Gujarati / ગુજરાતી","gu_IN","gu","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
"base.lang_he_IL","Hebrew / עברית","he_IL","he","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_he_IL","Hebrew / עברית","he_IL","he","Right-to-Left","[3,0]",".",",","%d.%m.%Y","%H:%M:%S","7"
"base.lang_hi_IN","Hindi / हिंदी","hi_IN","hi","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
"base.lang_hu","Hungarian / Magyar","hu_HU","hu","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
"base.lang_hu","Hungarian / Magyar","hu_HU","hu","Left-to-Right","[3,0]",",",".","%Y.%m.%d","%H:%M:%S","1"
"base.lang_id","Indonesian / Bahasa Indonesia","id_ID","id","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
"base.lang_it","Italian / Italiano","it_IT","it","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ja_JP","Japanese / 日本語","ja_JP","ja","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%H:%M:%S","7"
"base.lang_ja_JP","Japanese / 日本語","ja_JP","ja","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
"base.lang_kab_DZ","Kabyle / Taqbaylit","kab_DZ","kab","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
"base.lang_km","Khmer / ភាសាខ្មែរ","km_KH","km","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_ko_KP","Korean (KP) / 한국어 (KP)","ko_KP","ko_KP","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%I:%M:%S %p","1"
"base.lang_ko_KR","Korean (KR) / 한국어 (KR)","ko_KR","ko_KR","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
"base.lang_ko_KP","Korean (KP) / 한국어 (KP)","ko_KP","ko_KP","Left-to-Right","[3,0]",".",",","%Y.%m.%d","%I:%M:%S %p","1"
"base.lang_ko_KR","Korean (KR) / 한국어 (KR)","ko_KR","ko_KR","Left-to-Right","[3,0]",".",",","%Y.%m.%d","%H:%M:%S","7"
"base.lang_lo_LA","Lao / ພາສາລາວ","lo_LA","lo","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_lv","Latvian / latviešu valoda","lv_LV","lv","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_lv","Latvian / latviešu valoda","lv_LV","lv","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_lt","Lithuanian / Lietuvių kalba","lt_LT","lt","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
"base.lang_lb","Luxembourgish","lb_LU","lb","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_mk","Macedonian / македонски јазик","mk_MK","mk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ml","Malayalam / മലയാളം","ml_IN","ml","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_mn","Mongolian / монгол","mn_MN","mn","Left-to-Right","[3,0]",".","'","%Y-%m-%d","%H:%M:%S","7"
"base.lang_mn","Mongolian / монгол","mn_MN","mn","Left-to-Right","[3,0]",".","'","%d/%m/%Y","%H:%M:%S","7"
"base.lang_ms","Malay / Bahasa Melayu","ms_MY","ms","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
"base.lang_my","Burmese / ဗမာစာ","my_MM","my","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%I:%M:%S %p","7"
"base.lang_nb_NO","Norwegian Bokmål / Norsk bokmål","nb_NO","nb_NO","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_my","Burmese / ဗမာစာ","my_MM","my","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%I:%M:%S %p","7"
"base.lang_nb_NO","Norwegian Bokmål / Norsk bokmål","nb_NO","nb_NO","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_fa_IR","Persian / فارسی","fa_IR","fa","Right-to-Left","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","6"
"base.lang_pl","Polish / Język polski","pl_PL","pl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_pt_AO","Portuguese (AO) / Português (AO)","pt_AO","pt_AO","Left-to-Right","[3,0]",",","","%d-%m-%Y","%H:%M:%S","1"
"base.lang_pl","Polish / Język polski","pl_PL","pl","Left-to-Right","[3,0]",",","","%d.%m.%Y","%H:%M:%S","1"
"base.lang_pt_AO","Portuguese (AO) / Português (AO)","pt_AO","pt_AO","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_pt_BR","Portuguese (BR) / Português (BR)","pt_BR","pt_BR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
"base.lang_pt","Portuguese / Português","pt_PT","pt","Left-to-Right","[3,0]",",","","%d-%m-%Y","%H:%M:%S","1"
"base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ru","Russian / русский язык","ru_RU","ru","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_pt","Portuguese / Português","pt_PT","pt","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
"base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","1"
"base.lang_ru","Russian / русский язык","ru_RU","ru","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_sr@Cyrl","Serbian (Cyrillic) / српски","sr@Cyrl","sr@Cyrl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","7"
"base.lang_sr@latin","Serbian (Latin) / srpski","sr@latin","sr@latin","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_sk","Slovak / Slovenský jazyk","sk_SK","sk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_sl_SI","Slovenian / slovenščina","sl_SI","sl","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_sl_SI","Slovenian / slovenščina","sl_SI","sl","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_es_419","Spanish (Latin America) / Español (América Latina)","es_419","es_419","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_es_AR","Spanish (AR) / Español (AR)","es_AR","es_AR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_BO","Spanish (BO) / Español (BO)","es_BO","es_BO","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
@ -76,7 +76,7 @@
"base.lang_es_EC","Spanish (EC) / Español (EC)","es_EC","es_EC","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_es_GT","Spanish (GT) / Español (GT)","es_GT","es_GT","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_MX","Spanish (MX) / Español (MX)","es_MX","es_MX","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_PA","Spanish (PA) / Español (PA)","es_PA","es_PA","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_PA","Spanish (PA) / Español (PA)","es_PA","es_PA","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","7"
"base.lang_es_PE","Spanish (PE) / Español (PE)","es_PE","es_PE","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_PY","Spanish (PY) / Español (PY)","es_PY","es_PY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
"base.lang_es_UY","Spanish (UY) / Español (UY)","es_UY","es_UY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
@ -86,8 +86,8 @@
"base.lang_sv_SE","Swedish / Svenska","sv_SE","sv","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","1"
"base.lang_th","Thai / ภาษาไทย","th_TH","th","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
"base.lang_tl","Tagalog / Filipino","tl_PH","tl","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","1"
"base.lang_tr","Turkish / Türkçe","tr_TR","tr","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
"base.lang_uk_UA","Ukrainian / українська","uk_UA","uk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
"base.lang_tr","Turkish / Türkçe","tr_TR","tr","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","1"
"base.lang_uk_UA","Ukrainian / українська","uk_UA","uk","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","1"
"base.lang_vi_VN","Vietnamese / Tiếng Việt","vi_VN","vi","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_sq_AL","Albanian / Shqip","sq_AL","sq","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
"base.lang_te_IN","Telugu / తెలుగు","te_IN","te","Left-to-Right","[3,0]",".",",","%d-%m-%Y","%I:%M:%S %p","7"
"base.lang_sq_AL","Albanian / Shqip","sq_AL","sq","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
"base.lang_te_IN","Telugu / తెలుగు","te_IN","te","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"

1 id name code iso_code direction grouping decimal_point thousands_sep date_format time_format week_start
3 base.lang_am_ET Amharic / አምሃርኛ am_ET am_ET Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 7
4 base.lang_ar Arabic / الْعَرَبيّة ar_001 ar Right-to-Left [3,0] . , %d/%m/%Y %I:%M:%S %p 6
5 base.lang_ar_SY Arabic (Syria) / الْعَرَبيّة ar_SY ar_SY Right-to-Left [3,0] . , %d/%m/%Y %I:%M:%S %p 6
6 base.lang_az Azerbaijani / Azərbaycanca az_AZ az Left-to-Right [3,0] ,   %d/%m/%Y %Y-%m-%d %H:%M:%S 1
7 base.lang_eu_ES Basque / Euskara eu_ES eu_ES Left-to-Right [3,0] , %d/%m/%Y %H:%M:%S 1
8 base.lang_be Belarusian / Беларуская мова be_BY be Left-to-Right [3,0] , %d/%m/%Y %H:%M:%S 1
9 base.lang_bn_IN Bengali / বাংলা bn_IN bn_IN Left-to-Right [3,0] , %d/%m/%Y %I:%M:%S %p 1
10 base.lang_bs_BA Bosnian / bosanski jezik bs_BA bs Left-to-Right [3,0] , . %d/%m/%Y %Y-%m-%d %H:%M:%S 1
11 base.lang_bg Bulgarian / български език bg_BG bg Left-to-Right [3,0] , %d/%m/%Y %d.%m.%Y %H:%M:%S 1
12 base.lang_ca_ES Catalan / Català ca_ES ca_ES Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
13 base.lang_zh_CN Chinese, Simplified / 简体中文 zh_CN zh_CN Left-to-Right [3,0] . , %Y-%m-%d %Y/%m/%d %H:%M:%S 7
14 base.lang_zh_HK Chinese, Traditional (HK) / 繁體中文 (香港) zh_HK zh_HK Left-to-Right [3,0] . , %Y-%m-%d %m/%d/%Y %I:%M:%S %p 7
15 base.lang_zh_TW Chinese, Traditional (TW) / 繁體中文 (台灣) zh_TW zh_TW Left-to-Right [3,0] . , %Y/%m/%d %H:%M:%S 7
16 base.lang_hr Croatian / hrvatski jezik hr_HR hr Left-to-Right [3,0] , . %d/%m/%Y %d.%m.%Y %H:%M:%S 1
17 base.lang_cs_CZ Czech / Čeština cs_CZ cs_CZ Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
18 base.lang_da_DK Danish / Dansk da_DK da_DK Left-to-Right [3,0] , . %d-%m-%Y %d.%m.%Y %H:%M:%S 1
19 base.lang_nl_BE Dutch (BE) / Nederlands (BE) nl_BE nl_BE Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
20 base.lang_nl Dutch / Nederlands nl_NL nl Left-to-Right [3,0] , . %d-%m-%Y %H:%M:%S 1
21 base.lang_en_AU English (AU) en_AU en_AU Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 7
23 base.lang_en_GB English (UK) en_GB en_GB Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 1
24 base.lang_en_IN English (IN) en_IN en_IN Left-to-Right [3,2,0] . , %d/%m/%Y %I:%M:%S %p 7
25 base.lang_en_NZ English (NZ) en_NZ en_NZ Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 7
26 base.lang_et_EE Estonian / Eesti keel et_EE et Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
27 base.lang_fi Finnish / Suomi fi_FI fi Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
28 base.lang_fr_BE French (BE) / Français (BE) fr_BE fr_BE Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
29 base.lang_fr_CA French (CA) / Français (CA) fr_CA fr_CA Left-to-Right [3,0] ,   %Y-%m-%d %H:%M:%S 7
30 base.lang_fr_CH French (CH) / Français (CH) fr_CH fr_CH Left-to-Right [3,0] . ' %d/%m/%Y %d.%m.%Y %H:%M:%S 1
31 base.lang_fr French / Français fr_FR fr Left-to-Right [3,0] ,   %d/%m/%Y %H:%M:%S 1
32 base.lang_gl_ES Galician / Galego gl_ES gl Left-to-Right [3,0] , %d/%m/%Y %H:%M:%S 1
33 base.lang_ka_GE Georgian / ქართული ენა ka_GE ka Left-to-Right [3,0] , . %m/%d/%Y %d/%m/%Y %H:%M:%S 1
34 base.lang_de German / Deutsch de_DE de Left-to-Right [3,0] , . %d/%m/%Y %d.%m.%Y %H:%M:%S 1
35 base.lang_de_CH German (CH) / Deutsch (CH) de_CH de_CH Left-to-Right [3,0] . ' %d/%m/%Y %d.%m.%Y %H:%M:%S 1
36 base.lang_el_GR Greek / Ελληνικά el_GR el_GR Left-to-Right [3,0] , . %d/%m/%Y %I:%M:%S %p 1
37 base.lang_gu_IN Gujarati / ગુજરાતી gu_IN gu Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 7
38 base.lang_he_IL Hebrew / עברית he_IL he Right-to-Left [3,0] . , %d/%m/%Y %d.%m.%Y %H:%M:%S 7
39 base.lang_hi_IN Hindi / हिंदी hi_IN hi Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 7
40 base.lang_hu Hungarian / Magyar hu_HU hu Left-to-Right [3,0] , . %Y-%m-%d %Y.%m.%d %H:%M:%S 1
41 base.lang_id Indonesian / Bahasa Indonesia id_ID id Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 7
42 base.lang_it Italian / Italiano it_IT it Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
43 base.lang_ja_JP Japanese / 日本語 ja_JP ja Left-to-Right [3,0] . , %Y-%m-%d %Y/%m/%d %H:%M:%S 7
44 base.lang_kab_DZ Kabyle / Taqbaylit kab_DZ kab Left-to-Right [3,0] . , %d/%m/%Y %I:%M:%S %p 6
45 base.lang_km Khmer / ភាសាខ្មែរ km_KH km Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
46 base.lang_ko_KP Korean (KP) / 한국어 (KP) ko_KP ko_KP Left-to-Right [3,0] . , %Y/%m/%d %Y.%m.%d %I:%M:%S %p 1
47 base.lang_ko_KR Korean (KR) / 한국어 (KR) ko_KR ko_KR Left-to-Right [3,0] . , %Y/%m/%d %Y.%m.%d %H:%M:%S 7
48 base.lang_lo_LA Lao / ພາສາລາວ lo_LA lo Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
49 base.lang_lv Latvian / latviešu valoda lv_LV lv Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
50 base.lang_lt Lithuanian / Lietuvių kalba lt_LT lt Left-to-Right [3,0] , . %Y-%m-%d %H:%M:%S 1
51 base.lang_lb Luxembourgish lb_LU lb Left-to-Right [3,0] ,   %d/%m/%Y %H:%M:%S 1
52 base.lang_mk Macedonian / македонски јазик mk_MK mk Left-to-Right [3,0] ,   %d/%m/%Y %H:%M:%S 1
53 base.lang_ml Malayalam / മലയാളം ml_IN ml Left-to-Right [3,0] ,   %d/%m/%Y %H:%M:%S 1
54 base.lang_mn Mongolian / монгол mn_MN mn Left-to-Right [3,0] . ' %Y-%m-%d %d/%m/%Y %H:%M:%S 7
55 base.lang_ms Malay / Bahasa Melayu ms_MY ms Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 1
56 base.lang_my Burmese / ဗမာစာ my_MM my Left-to-Right [3,0] . , %Y-%m-%d %m/%d/%Y %I:%M:%S %p 7
57 base.lang_nb_NO Norwegian Bokmål / Norsk bokmål nb_NO nb_NO Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
58 base.lang_fa_IR Persian / فارسی fa_IR fa Right-to-Left [3,0] . , %Y/%m/%d %H:%M:%S 6
59 base.lang_pl Polish / Język polski pl_PL pl Left-to-Right [3,0] , %d/%m/%Y %d.%m.%Y %H:%M:%S 1
60 base.lang_pt_AO Portuguese (AO) / Português (AO) pt_AO pt_AO Left-to-Right [3,0] , %d-%m-%Y %d/%m/%Y %H:%M:%S 1
61 base.lang_pt_BR Portuguese (BR) / Português (BR) pt_BR pt_BR Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 7
62 base.lang_pt Portuguese / Português pt_PT pt Left-to-Right [3,0] , %d-%m-%Y %d/%m/%Y %H:%M:%S 1
63 base.lang_ro Romanian / română ro_RO ro Left-to-Right [3,0] , . %d/%m/%Y %d.%m.%Y %H:%M:%S 1
64 base.lang_ru Russian / русский язык ru_RU ru Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
65 base.lang_sr@Cyrl Serbian (Cyrillic) / српски sr@Cyrl sr@Cyrl Left-to-Right [3,0] , %d/%m/%Y %H:%M:%S 7
66 base.lang_sr@latin Serbian (Latin) / srpski sr@latin sr@latin Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
67 base.lang_sk Slovak / Slovenský jazyk sk_SK sk Left-to-Right [3,0] ,   %d/%m/%Y %H:%M:%S 1
68 base.lang_sl_SI Slovenian / slovenščina sl_SI sl Left-to-Right [3,0] ,   . %d/%m/%Y %H:%M:%S 1
69 base.lang_es_419 Spanish (Latin America) / Español (América Latina) es_419 es_419 Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
70 base.lang_es_AR Spanish (AR) / Español (AR) es_AR es_AR Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 7
71 base.lang_es_BO Spanish (BO) / Español (BO) es_BO es_BO Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
76 base.lang_es_EC Spanish (EC) / Español (EC) es_EC es_EC Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
77 base.lang_es_GT Spanish (GT) / Español (GT) es_GT es_GT Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
78 base.lang_es_MX Spanish (MX) / Español (MX) es_MX es_MX Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
79 base.lang_es_PA Spanish (PA) / Español (PA) es_PA es_PA Left-to-Right [3,0] . , %d/%m/%Y %m/%d/%Y %H:%M:%S 7
80 base.lang_es_PE Spanish (PE) / Español (PE) es_PE es_PE Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
81 base.lang_es_PY Spanish (PY) / Español (PY) es_PY es_PY Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 7
82 base.lang_es_UY Spanish (UY) / Español (UY) es_UY es_UY Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
86 base.lang_sv_SE Swedish / Svenska sv_SE sv Left-to-Right [3,0] ,   %Y-%m-%d %H:%M:%S 1
87 base.lang_th Thai / ภาษาไทย th_TH th Left-to-Right [3,0] . , %d/%m/%Y %H:%M:%S 7
88 base.lang_tl Tagalog / Filipino tl_PH tl Left-to-Right [3,0] . , %m/%d/%Y %H:%M:%S 1
89 base.lang_tr Turkish / Türkçe tr_TR tr Left-to-Right [3,0] , . %d-%m-%Y %d.%m.%Y %H:%M:%S 1
90 base.lang_uk_UA Ukrainian / українська uk_UA uk Left-to-Right [3,0] ,   %d/%m/%Y %d.%m.%Y %H:%M:%S 1
91 base.lang_vi_VN Vietnamese / Tiếng Việt vi_VN vi Left-to-Right [3,0] , . %d/%m/%Y %H:%M:%S 1
92 base.lang_sq_AL Albanian / Shqip sq_AL sq Left-to-Right [3,0] , . %Y-%m-%d %d/%m/%Y %H:%M:%S 1
93 base.lang_te_IN Telugu / తెలుగు te_IN te Left-to-Right [3,0] . , %d-%m-%Y %d/%m/%Y %I:%M:%S %p 7

View file

@ -144,7 +144,7 @@
<record id="bg" model="res.country">
<field name="name">Bulgaria</field>
<field name="code">bg</field>
<field name="currency_id" ref="BGN" />
<field name="currency_id" ref="EUR" />
<field eval="359" name="phone_code" />
<field name="vat_label">VAT</field>
</record>
@ -344,7 +344,7 @@
<record id="cw" model="res.country">
<field name="name">Curaçao</field>
<field name="code">cw</field>
<field name="currency_id" ref="ANG" />
<field name="currency_id" ref="XCG" />
<field eval="599" name="phone_code" />
</record>
<record id="cx" model="res.country">
@ -1120,6 +1120,7 @@
<record id="pe" model="res.country">
<field name="name">Peru</field>
<field name="code">pe</field>
<field name='zip_required'>0</field>
<field name="currency_id" ref="PEN" />
<field eval="51" name="phone_code" />
<field name="vat_label">RUC</field>
@ -1354,7 +1355,7 @@
<record id="sx" model="res.country">
<field name="name">Sint Maarten (Dutch part)</field>
<field name="code">sx</field>
<field name="currency_id" ref="ANG" />
<field name="currency_id" ref="XCG" />
<field eval="1721" name="phone_code" />
</record>
<record id="sy" model="res.country">

View file

@ -806,6 +806,17 @@
<field name="position">before</field>
</record>
<record id="XCG" model="res.currency">
<field name="name">XCG</field>
<field name="full_name">Caribbean Guilder</field>
<field name="symbol">Cg</field>
<field name="rounding">0.01</field>
<field name="active" eval="False"/>
<field name="currency_unit_label">Guilder</field>
<field name="currency_subunit_label">Cents</field>
<field name="position">after</field>
</record>
<record id="DJF" model="res.currency">
<field name="name">DJF</field>
<field name="iso_numeric">262</field>
@ -1726,7 +1737,7 @@
<field name="symbol">QR</field>
<field name="rounding">0.01</field>
<field name="active" eval="False"/>
<field name="currency_unit_label">Rial</field>
<field name="currency_unit_label">Riyal</field>
<field name="currency_subunit_label">Dirham</field>
</record>

View file

@ -432,6 +432,12 @@
<field name="rate">2.11</field>
</record>
<record forcecreate="0" id="rateXCG" model="res.currency.rate">
<field name="currency_id" ref="XCG" />
<field name="name">2025-01-15</field>
<field name="rate">1.80302</field>
</record>
<record forcecreate="0" id="rateDJF" model="res.currency.rate">
<field name="currency_id" ref="DJF" />
<field name="name">2010-01-01</field>

View file

@ -57,7 +57,7 @@
<field name="vat">US12345672</field>
</record>
<record id="res_partner_2" model="res.partner">
<field name="name">Deco Addict</field>
<field name="name">Acme Corporation</field>
<field eval="[Command.set([ref('base.res_partner_category_14')])]" name="category_id"/>
<field name="is_company">1</field>
<field name="street">77 Santa Barbara Rd</field>
@ -65,9 +65,9 @@
<field name="state_id" ref='state_us_5'/>
<field name="zip">94523</field>
<field name="country_id" ref="base.us"/>
<field name="email">deco_addict@yourcompany.example.com</field>
<field name="email">acme_corp@yourcompany.example.com</field>
<field name="phone">(603)-996-3829</field>
<field name="website">http://www.deco-addict.com</field>
<field name="website">http://www.acme-example-company.com</field>
<field name="image_1920" type="base64" file="base/static/img/res_partner_2-image.png"/>
<field name="vat">US12345673</field>
</record>

View file

@ -7,7 +7,7 @@
<field name="company_id" ref="main_company"/>
<field name="company_ids" eval="[Command.link(ref('main_company'))]"/>
<field name="email">odoobot@example.com</field>
<field name="signature">System</field>
<field name="signature" type="html"><div>System</div></field>
</record>
<!-- user 2 is the human admin user -->
@ -18,7 +18,7 @@
<field name="company_id" ref="main_company"/>
<field name="company_ids" eval="[Command.link(ref('main_company'))]"/>
<field name="group_ids" eval="[Command.set([])]"/>
<field name="signature">Administrator</field>
<field name="signature" type="html"><div>Administrator</div></field>
</record>
<record id="user_admin_settings" model="res.users.settings" forcecreate="0">

View file

@ -36,7 +36,7 @@
<field name="partner_id" ref="base.partner_demo"/>
<field name="login">demo</field>
<field name="password">demo</field>
<field name="signature">Mr Demo</field>
<field name="signature" type="html"><div>Mr Demo</div></field>
<field name="company_id" ref="main_company"/>
<field name="group_ids" eval="[Command.set([ref('base.group_user'), ref('base.group_partner_manager'), ref('base.group_allow_export')])]"/>
<field name="image_1920" type="base64" file="base/static/img/user_demo-image.png"/>
@ -66,7 +66,7 @@
</record>
<record id="base.user_admin" model="res.users">
<field name="signature">Mitchell Admin</field>
<field name="signature" type="html"><div>Mitchell Admin</div></field>
</record>
<!-- Portal : partner and user -->
@ -86,7 +86,7 @@
<field name="partner_id" ref="partner_demo_portal"/>
<field name="login">portal</field>
<field name="password">portal</field>
<field name="signature">Mr Demo Portal</field>
<field name="signature" type="html"><div>Mr Demo Portal</div></field>
<field name="group_ids" eval="[Command.clear()]"/><!-- Avoid auto-including this user in any default group -->
</record>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -512,7 +512,7 @@ class IrActionsServerHistory(models.Model):
self.display_name = False
for history in self.filtered('create_date'):
locale = get_lang(self.env).code
tzinfo = pytz.timezone(self.env.user.tz)
tzinfo = self.env.tz
datetime = history.create_date.replace(microsecond=0)
datetime = pytz.utc.localize(datetime, is_dst=False)
datetime = datetime.astimezone(tzinfo) if tzinfo else datetime

View file

@ -22,7 +22,7 @@ from lxml import etree
from markupsafe import Markup
from odoo import api, fields, models, modules, tools, _
from odoo.exceptions import UserError, AccessError, RedirectWarning
from odoo.exceptions import UserError, AccessError, RedirectWarning, ValidationError
from odoo.fields import Domain
from odoo.service import security
from odoo.http import request, root
@ -80,6 +80,7 @@ class WkhtmlInfo(typing.NamedTuple):
dpi_zoom_ratio: bool
bin: str
version: str
is_patched_qt: bool
wkhtmltoimage_bin: str
wkhtmltoimage_version: tuple[str, ...] | None
@ -89,6 +90,7 @@ def _wkhtml() -> WkhtmlInfo:
state = 'install'
bin_path = 'wkhtmltopdf'
version = ''
is_patched_qt = False
dpi_zoom_ratio = False
try:
bin_path = find_in_path('wkhtmltopdf')
@ -101,6 +103,8 @@ def _wkhtml() -> WkhtmlInfo:
_logger.info('Will use the Wkhtmltopdf binary at %s', bin_path)
out, _err = process.communicate()
version = out.decode('ascii')
if '(with patched qt)' in version:
is_patched_qt = True
match = re.search(r'([0-9.]+)', version)
if match:
version = match.group(0)
@ -144,6 +148,7 @@ def _wkhtml() -> WkhtmlInfo:
dpi_zoom_ratio=dpi_zoom_ratio,
bin=bin_path,
version=version,
is_patched_qt=is_patched_qt,
wkhtmltoimage_bin=image_bin_path,
wkhtmltoimage_version=wkhtmltoimage_version,
)
@ -327,7 +332,7 @@ class IrActionsReport(models.Model):
command_args.extend(['--page-width', str(paperformat_id.page_width) + 'mm'])
command_args.extend(['--page-height', str(paperformat_id.page_height) + 'mm'])
if specific_paperformat_args and specific_paperformat_args.get('data-report-margin-top'):
if specific_paperformat_args and 'data-report-margin-top' in specific_paperformat_args:
command_args.extend(['--margin-top', str(specific_paperformat_args['data-report-margin-top'])])
else:
command_args.extend(['--margin-top', str(paperformat_id.margin_top)])
@ -346,14 +351,14 @@ class IrActionsReport(models.Model):
if _wkhtml().dpi_zoom_ratio:
command_args.extend(['--zoom', str(96.0 / dpi)])
if specific_paperformat_args and specific_paperformat_args.get('data-report-header-spacing'):
if specific_paperformat_args and 'data-report-header-spacing' in specific_paperformat_args:
command_args.extend(['--header-spacing', str(specific_paperformat_args['data-report-header-spacing'])])
elif paperformat_id.header_spacing:
command_args.extend(['--header-spacing', str(paperformat_id.header_spacing)])
command_args.extend(['--margin-left', str(paperformat_id.margin_left)])
if specific_paperformat_args and specific_paperformat_args.get('data-report-margin-bottom'):
if specific_paperformat_args and 'data-report-margin-bottom' in specific_paperformat_args:
command_args.extend(['--margin-bottom', str(specific_paperformat_args['data-report-margin-bottom'])])
else:
command_args.extend(['--margin-bottom', str(paperformat_id.margin_bottom)])
@ -457,15 +462,15 @@ class IrActionsReport(models.Model):
return bodies, res_ids, header, footer, specific_paperformat_args
def _run_wkhtmltoimage(self, bodies, width, height, image_format="jpg"):
def _run_wkhtmltoimage(self, bodies, width, height, image_format="jpg") -> list[bytes | None]:
"""
:bodies str: valid html documents as strings
:param width int: width in pixels
:param height int: height in pixels
:param image_format union['jpg', 'png']: format of the image
:return list[bytes|None]:
:param str bodies: valid html documents as strings
:param int width: width in pixels
:param int height: height in pixels
:param image_format: format of the image
:type image_format: typing.Literal['jpg', 'png']
"""
if (modules.module.current_test or tools.config['test_enable']) and not self.env.context.get('force_image_rendering'):
if modules.module.current_test:
return [None] * len(bodies)
wkhtmltoimage_version = _wkhtml().wkhtmltoimage_version
if not wkhtmltoimage_version or wkhtmltoimage_version < parse_version('0.12.0'):
@ -617,8 +622,7 @@ class IrActionsReport(models.Model):
pass
case 1:
if body_idx:
wk_version = _wkhtml().version
if '(with patched qt)' not in wk_version:
if not _wkhtml().is_patched_qt:
if modules.module.current_test:
raise unittest.SkipTest("Unable to convert multiple documents via wkhtmltopdf using unpatched QT")
raise UserError(_("Tried to convert multiple documents in wkhtmltopdf using unpatched QT"))
@ -798,7 +802,10 @@ class IrActionsReport(models.Model):
handle_error(error=e, error_stream=stream)
result_stream = io.BytesIO()
streams.append(result_stream)
writer.write(result_stream)
try:
writer.write(result_stream)
except PdfReadError:
raise UserError(_("Odoo is unable to merge the generated PDFs."))
return result_stream
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
@ -1198,28 +1205,10 @@ class IrActionsReport(models.Model):
@api.model
def _prepare_local_attachments(self, attachments):
attachments_with_data = self.env['ir.attachment']
for attachment in attachments:
if not attachment._is_remote_source():
attachments_with_data |= attachment
elif (stream := attachment._to_http_stream()) and stream.url:
# call `_to_http_stream()` in case the attachment is an url or cloud storage attachment
if attachment._is_remote_source():
try:
response = requests.get(stream.url, timeout=10)
response.raise_for_status()
attachment_data = response.content
if not attachment_data:
_logger.warning("Attachment %s at with URL %s retrieved successfully, but no content was found.", attachment.id, attachment.url)
continue
attachments_with_data |= self.env['ir.attachment'].new({
'db_datas': attachment_data,
'name': attachment.name,
'mimetype': attachment.mimetype,
'res_model': attachment.res_model,
'res_id': attachment.res_id
})
except requests.exceptions.RequestException as e:
_logger.error("Request for attachment %s with URL %s failed: %s", attachment.id, attachment.url, e)
else:
_logger.error("Unexpected edge case: Is not being considered as a local or remote attachment, attachment ID:%s will be skipped.", attachment.id)
return attachments_with_data
attachment._migrate_remote_to_local()
except (ValidationError, requests.exceptions.RequestException) as e:
_logger.error("Failed to migrate attachment %s to local: %s", attachment.id, e)
return attachments.filtered(lambda a: not a._is_remote_source())

View file

@ -166,11 +166,13 @@ class IrAsset(models.Model):
:param bundle: name of the bundle from which to fetch the file paths
:param addons: list of addon names as strings
:param css: boolean: whether or not to include style files
:param js: boolean: whether or not to include script files
:param xml: boolean: whether or not to include template files
:param asset_paths: the AssetPath object to fill
:param seen: a list of bundles already checked to avoid circularity
:param assets_params: Keyword arguments:
* css: bool: whether or not to include style files
* js: bool: whether or not to include script files
* xml: bool: whether or not to include template files
"""
if bundle in seen:
raise Exception("Circular assets bundle declaration: %s" % " > ".join(seen + [bundle]))
@ -343,7 +345,8 @@ class IrAsset(models.Model):
if addon_manifest:
if addon not in installed:
# Assert that the path is in the installed addons
raise Exception(f"Unallowed to fetch files from addon {addon} for file {path_def}")
raise Exception(f"""Unallowed to fetch files from addon {addon} for file {path_def}. """
f"""Addon {addon} is not installed""")
addons_path = addon_manifest.addons_path
full_path = os.path.normpath(os.path.join(addons_path, *path_parts))
# forbid escape from the current addon

View file

@ -7,22 +7,35 @@ import hashlib
import logging
import mimetypes
import os
import psycopg2
import re
import uuid
import warnings
import werkzeug
from collections import defaultdict
from collections.abc import Collection
from odoo import api, fields, models, _
from odoo.exceptions import AccessError, MissingError, ValidationError, UserError
import psycopg2
import werkzeug
from odoo import _, api, fields, models
from odoo.exceptions import AccessError, MissingError, UserError, ValidationError
from odoo.fields import Domain
from odoo.http import Stream, root, request
from odoo.tools import config, consteq, human_size, image, split_every, str2bool, OrderedSet
from odoo.http import Stream, request, root
from odoo.tools import (
OrderedSet,
config,
consteq,
human_size,
image,
split_every,
str2bool,
)
from odoo.tools.constants import PREFETCH_MAX
from odoo.tools.mimetypes import guess_mimetype, fix_filename_extension, _olecf_mimetypes
from odoo.tools.mimetypes import (
MIMETYPE_HEAD_SIZE,
_olecf_mimetypes,
fix_filename_extension,
guess_mimetype,
)
from odoo.tools.misc import limited_field_access_token
_logger = logging.getLogger(__name__)
@ -257,6 +270,14 @@ class IrAttachment(models.Model):
else:
attach.raw = attach.db_datas
def _get_pdf_raw(self):
self.ensure_one()
if self.type != 'binary':
return False
if self.mimetype != 'application/pdf':
return False
return self.raw
def _inverse_raw(self):
self._set_attachment_data(lambda a: a.raw or b'')
@ -378,7 +399,10 @@ class IrAttachment(models.Model):
nw, nh = map(int, max_resolution.split('x'))
if w > nw or h > nh:
img = img.resize(nw, nh)
quality = int(ICP('base.image_autoresize_quality', 80))
if _subtype == 'jpeg': # Do not affect PNGs color palette
quality = int(ICP('base.image_autoresize_quality', 80))
else:
quality = 0
image_data = img.image_quality(quality=quality)
if is_raw:
values['raw'] = image_data
@ -407,19 +431,15 @@ class IrAttachment(models.Model):
return values
@api.model
def _index(self, bin_data, file_type, checksum=None):
def _index(self, bin_data: bytes, file_type: str, checksum=None) -> str | None:
""" compute the index content of the given binary data.
This is a python implementation of the unix command 'strings'.
:param bin_data : datas in binary form
:return index_content : string containing all the printable character of the binary data
This is a python implementation of the unix command 'strings'.
"""
index_content = False
if file_type:
index_content = file_type.split('/')[0]
if index_content == 'text': # compute index_content only for text type
words = re.findall(b"[\x20-\x7E]{4,}", bin_data)
index_content = b"\n".join(words).decode('ascii')
return index_content
# compute index_content only for text type
if file_type and file_type.startswith('text/'):
words = re.findall(rb"[\x20-\x7E]{4,}", bin_data)
return b"\n".join(words).decode('ascii')
return None
@api.model
def get_serving_groups(self):
@ -595,6 +615,7 @@ class IrAttachment(models.Model):
if (
not self.env.context.get('skip_res_field_check')
and not any(d.field_expr in ('id', 'res_field') for d in domain.iter_conditions())
and not bypass_access
):
disable_binary_fields_attachments = True
domain &= Domain('res_field', '=', False)
@ -734,17 +755,20 @@ class IrAttachment(models.Model):
checksum_raw_map = {}
for values in vals_list:
values = self._check_contents(values)
raw, datas = values.pop('raw', None), values.pop('datas', None)
if raw or datas:
# needs to be popped in all cases to bypass `_inverse_datas`
datas = values.pop('datas', None)
if raw := values.get('raw'):
if isinstance(raw, str):
# b64decode handles str input but raw needs explicit encoding
raw = raw.encode()
elif not raw:
raw = base64.b64decode(datas or b'')
values['raw'] = raw.encode()
elif datas:
values['raw'] = base64.b64decode(datas)
else:
values['raw'] = b''
values = self._check_contents(values)
if raw := values.pop('raw'):
values.update(self._get_datas_related_values(raw, values['mimetype']))
if raw:
checksum_raw_map[values['checksum']] = raw
checksum_raw_map[values['checksum']] = raw
# 'check()' only uses res_model and res_id from values, and make an exists.
# We can group the values by model, res_id to make only one query when
@ -858,7 +882,7 @@ class IrAttachment(models.Model):
mimetype = file.content_type
filename = file.filename
elif mimetype == 'GUESS':
head = file.read(1024)
head = file.read(MIMETYPE_HEAD_SIZE)
file.seek(-len(head), 1) # rewind
mimetype = guess_mimetype(head)
filename = fix_filename_extension(file.filename, mimetype)
@ -943,3 +967,9 @@ class IrAttachment(models.Model):
self.check_access('read')
return True
return super()._can_return_content(field_name, access_token)
def _migrate_remote_to_local(self):
if self.type == 'binary':
return
if self.type == 'url':
raise ValidationError(_("URL attachment (%s) shouldn't be migrated to local.", self.id))

View file

@ -1,17 +1,17 @@
import logging
import werkzeug.http
from datetime import datetime
from mimetypes import guess_extension
import werkzeug.http
from odoo import models
from odoo.exceptions import AccessError, MissingError, UserError
from odoo.exceptions import MissingError, UserError
from odoo.http import Stream, request
from odoo.tools import file_open, replace_exceptions
from odoo.tools.image import image_process, image_guess_size_from_field_name
from odoo.tools.mimetypes import guess_mimetype, get_extension
from odoo.tools.image import image_guess_size_from_field_name, image_process
from odoo.tools.mimetypes import MIMETYPE_HEAD_SIZE, get_extension, guess_mimetype
from odoo.tools.misc import verify_limited_field_access_token
DEFAULT_PLACEHOLDER_PATH = 'web/static/img/placeholder.png'
_logger = logging.getLogger(__name__)
@ -128,10 +128,10 @@ class IrBinary(models.AbstractModel):
stream.mimetype = mimetype
elif not stream.mimetype:
if stream.type == 'data':
head = stream.data[:1024]
head = stream.data[:MIMETYPE_HEAD_SIZE]
else:
with open(stream.path, 'rb') as file:
head = file.read(1024)
head = file.read(MIMETYPE_HEAD_SIZE)
stream.mimetype = guess_mimetype(head, default=default_mimetype)
if filename:

View file

@ -1,17 +1,21 @@
from __future__ import annotations
import contextvars
import copy
import logging
import os
import threading
import time
import os
import psycopg2
import psycopg2.errors
import typing
from datetime import datetime, timedelta, timezone
import psycopg2
import psycopg2.errors
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, sql_db
from odoo.exceptions import LockError, UserError
from odoo.http import serialize_exception
from odoo.modules import Manifest
from odoo.modules.registry import Registry
from odoo.tools import SQL
@ -19,6 +23,7 @@ from odoo.tools.constants import GC_UNLINK_LIMIT
if typing.TYPE_CHECKING:
from collections.abc import Iterable
from odoo.sql_db import BaseCursor
_logger = logging.getLogger(__name__)
@ -59,6 +64,30 @@ class CompletionStatus: # inherit from enum.StrEnum in 3.11
FAILED = 'failed'
class ListLogHandler(logging.Handler):
def __init__(self, logger, level=logging.NOTSET):
super().__init__(level)
self.logger = logger
self.list_log_handler = contextvars.ContextVar('list_log_handler', default=None)
def emit(self, record):
logs = self.list_log_handler.get(None)
if logs is None:
return
record = copy.copy(record)
logs.append(record)
def __enter__(self):
# set a list in the current context
logs = []
self.list_log_handler.set(logs)
self.logger.addHandler(self)
return logs
def __exit__(self, *exc):
self.logger.removeHandler(self)
class IrCron(models.Model):
""" Model describing cron jobs (also called actions or tasks).
"""
@ -135,7 +164,23 @@ class IrCron(models.Model):
job = self._acquire_one_job(cron_cr, self.id, include_not_ready=True)
if not job:
raise UserError(self.env._("Job '%s' already executing", self.name))
self._process_job(cron_cr, job)
with ListLogHandler(_logger, logging.ERROR) as capture:
self._process_job(cron_cr, job)
if log_record := next((lr for lr in capture if getattr(lr, 'exc_info', None)), None):
_exc_type, exception, _traceback = log_record.exc_info
e = RuntimeError()
e.__cause__ = exception
error = {
'code': 0, # we don't care of this code
'message': "Odoo Server Error",
'data': serialize_exception(e),
}
return {
'type': 'ir.actions.client',
'tag': 'display_exception',
'params': error,
}
return True
@staticmethod
@ -187,7 +232,7 @@ class IrCron(models.Model):
continue
_logger.debug("job %s acquired", job_id)
# take into account overridings of _process_job() on that database
registry = Registry(db_name)
registry = Registry(db_name).check_signaling()
registry[IrCron._name]._process_job(cron_cr, job)
cron_cr.commit()
_logger.debug("job %s updated and released", job_id)
@ -447,39 +492,65 @@ class IrCron(models.Model):
# stop after MIN_RUNS_PER_JOB runs and MIN_TIME_PER_JOB seconds, or
# upon full completion or failure
while (
while status is None and (
loop_count < MIN_RUNS_PER_JOB
or time.monotonic() < env.context['cron_end_time']
):
cron, progress = cron._add_progress(timed_out_counter=timed_out_counter)
job_cr.commit()
success = False
try:
# signaling check and commit is done inside `_callback`
cron._callback(job['cron_name'], job['ir_actions_server_id'])
success = True
except Exception: # noqa: BLE001
_logger.exception('Job %r (%s) server action #%s failed',
job['cron_name'], job['id'], job['ir_actions_server_id'])
if progress.done and progress.remaining:
# we do not consider it a failure if some progress has
# been committed
status = CompletionStatus.PARTIALLY_DONE
else:
status = CompletionStatus.FAILED
else:
if not progress.remaining:
# assume the server action doesn't use the progress API
# and that there is nothing left to process
status = CompletionStatus.FULLY_DONE
else:
status = CompletionStatus.PARTIALLY_DONE
if not progress.done:
break
if status == CompletionStatus.FULLY_DONE and progress.deactivate:
job['active'] = False
finally:
done, remaining = progress.done, progress.remaining
match (success, done, remaining):
case (False, d, r) if d and r:
# The cron action failed but was nonetheless able
# to commit some progress.
# Hopefully this failure is temporary.
pass
case (False, _, _):
# The cron action failed, and was unable to commit
# any progress this time. Consider it failed even
# if it progressed in a previous loop iteration.
status = CompletionStatus.FAILED
case (True, _, 0):
# The cron action completed. Either it doesn't use
# the progress API, either it reported no remaining
# stuff to process.
status = CompletionStatus.FULLY_DONE
if progress.deactivate:
job['active'] = False
case (True, 0, _) if loop_count == 0:
# The cron action was able to determine there are
# remaining records to process, but couldn't
# process any of them.
# Hopefully this condition is temporary.
status = CompletionStatus.PARTIALLY_DONE
_logger.warning("Job %r (%s) processed no record",
job['cron_name'], job['id'])
case (True, 0, _):
# The cron action was able to determine there are
# remaining records to process, did process some
# records in a previous loop iteration, but
# processed none this time.
status = CompletionStatus.PARTIALLY_DONE
case (True, _, _):
# The cron action was able to process some but not
# all records. Loop.
pass
loop_count += 1
progress.timed_out_counter = 0
timed_out_counter = 0
@ -488,9 +559,7 @@ class IrCron(models.Model):
_logger.debug('Job %r (%s) processed %s records, %s records remaining',
job['cron_name'], job['id'], done, remaining)
if status in (CompletionStatus.FULLY_DONE, CompletionStatus.FAILED):
break
status = status or CompletionStatus.PARTIALLY_DONE
_logger.info(
'Job %r (%s) %s (#loop %s; done %s; remaining %s; duration %.2fs)',
job['cron_name'], job['id'], status,

View file

@ -26,13 +26,20 @@ class IrDefault(models.Model):
condition = fields.Char('Condition', help="If set, applies the default upon condition.")
json_value = fields.Char('Default Value (JSON format)', required=True)
@api.constrains('json_value')
@api.constrains('json_value', 'field_id')
def _check_json_format(self):
for record in self:
model_name = record.sudo().field_id.model_id.model
model = self.env[model_name]
field = model._fields[record.field_id.name]
try:
json.loads(record.json_value)
value = json.loads(record.json_value)
field.convert_to_cache(value, model)
except json.JSONDecodeError:
raise ValidationError(self.env._('Invalid JSON format in Default Value field.'))
except Exception: # noqa: BLE001
raise ValidationError(self.env._("Invalid value in Default Value field. Expected type '%(field_type)s' for '%(model_name)s.%(field_name)s'.",
field_type=record.field_id.ttype, model_name=model_name, field_name=record.field_id.name))
@api.model_create_multi
def create(self, vals_list):
@ -102,7 +109,7 @@ class IrDefault(models.Model):
('user_id', '=', user_id),
('company_id', '=', company_id),
('condition', '=', condition),
])
], limit=1)
if default:
# Avoid clearing the cache if nothing changes
if default.json_value != json_value:

View file

@ -158,12 +158,10 @@ class IrHttp(models.AbstractModel):
uni = unicodedata.normalize('NFKD', value)
slugified_segments = []
for slug in re.split('-|_| ', uni):
slug = re.sub(r'([^\w-])+', '', slug)
slug = re.sub(r'--+', '-', slug)
slug = slug.strip('-')
slug = re.sub(r'([^\w])+', '', slug)
if slug:
slugified_segments.append(slug.lower())
slugified_str = '-'.join(slugified_segments)
slugified_str = unicodedata.normalize('NFC', '-'.join(slugified_segments))
return slugified_str[:max_length]
@classmethod

View file

@ -35,7 +35,8 @@ class IrLogging(models.Model):
def init(self):
super(IrLogging, self).init()
self.env.cr.execute("select 1 from information_schema.constraint_column_usage where table_name = 'ir_logging' and constraint_name = 'ir_logging_write_uid_fkey'")
self.env.cr.execute("select 1 from information_schema.constraint_column_usage where table_name = 'ir_logging' and constraint_name = 'ir_logging_write_uid_fkey'"
" and table_schema = current_schema")
if self.env.cr.rowcount:
# DROP CONSTRAINT unconditionally takes an ACCESS EXCLUSIVE lock
# on the table, even "IF EXISTS" is set and not matching; disabling

View file

@ -1,27 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import datetime
import email
import email.policy
import functools
import idna
import logging
import re
import smtplib
import ssl
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import make_msgid
from socket import gaierror, timeout
import idna
import OpenSSL
from OpenSSL import crypto as SSLCrypto
from OpenSSL.crypto import Error as SSLCryptoError, FILETYPE_PEM
from OpenSSL.SSL import Error as SSLError, VERIFY_PEER, VERIFY_FAIL_IF_NO_PEER_CERT
from OpenSSL.crypto import FILETYPE_PEM
from OpenSSL.crypto import Error as SSLCryptoError
from OpenSSL.SSL import VERIFY_FAIL_IF_NO_PEER_CERT, VERIFY_PEER
from OpenSSL.SSL import Error as SSLError
from urllib3.contrib.pyopenssl import PyOpenSSLContext, get_subj_alt_name
from odoo import api, fields, models, tools, _, modules
from odoo import _, api, fields, models, modules, tools
from odoo.exceptions import UserError
from odoo.tools import formataddr, email_normalize, encapsulate_email, email_domain_extract, email_domain_normalize, human_size
from odoo.tools import (
email_domain_extract,
email_domain_normalize,
email_normalize,
encapsulate_email,
formataddr,
human_size,
parse_version,
)
if parse_version(OpenSSL.__version__) >= parse_version('24.3.0'):
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
else:
from OpenSSL import crypto as SSLCrypto
from OpenSSL.crypto import FILETYPE_PEM
from OpenSSL.crypto import Error as SSLCryptoError
def load_pem_private_key(pem_key, password):
return SSLCrypto.load_privatekey(FILETYPE_PEM, pem_key)
def load_pem_x509_certificate(pem_cert):
return SSLCrypto.load_certificate(FILETYPE_PEM, pem_cert)
try:
# urllib3 1.26 (ubuntu jammy and up, debian bullseye and up)
@ -423,12 +448,11 @@ class IrMail_Server(models.Model):
)
else: # ssl, starttls
ssl_context.verify_mode = ssl.CERT_NONE
smtp_ssl_certificate = base64.b64decode(mail_server.smtp_ssl_certificate)
certificate = SSLCrypto.load_certificate(FILETYPE_PEM, smtp_ssl_certificate)
smtp_ssl_private_key = base64.b64decode(mail_server.smtp_ssl_private_key)
private_key = SSLCrypto.load_privatekey(FILETYPE_PEM, smtp_ssl_private_key)
ssl_context._ctx.use_certificate(certificate)
ssl_context._ctx.use_privatekey(private_key)
ssl_context._ctx.use_certificate(load_pem_x509_certificate(
base64.b64decode(mail_server.smtp_ssl_certificate)))
ssl_context._ctx.use_privatekey(load_pem_private_key(
base64.b64decode(mail_server.smtp_ssl_private_key),
password=None))
# Check that the private key match the certificate
ssl_context._ctx.check_privatekey()
except SSLCryptoError as e:
@ -607,8 +631,7 @@ class IrMail_Server(models.Model):
for (fname, fcontent, mime) in attachments:
maintype, subtype = mime.split('/') if mime and '/' in mime else ('application', 'octet-stream')
if maintype == 'message' and subtype == 'rfc822':
# Use binary encoding for "message/rfc822" attachments (see RFC 2046 Section 5.2.1)
msg.add_attachment(fcontent, maintype, subtype, filename=fname, cte='binary')
msg.add_attachment(BytesParser().parsebytes(fcontent), filename=fname)
else:
msg.add_attachment(fcontent, maintype, subtype, filename=fname)
return msg

View file

@ -365,6 +365,11 @@ class IrModel(models.Model):
if crons:
crons.unlink()
# delete related ir_model_data
model_data = self.env['ir.model.data'].search([('model', 'in', self.mapped('model'))])
if model_data:
model_data.unlink()
self._drop_table()
res = super().unlink()
@ -625,7 +630,12 @@ class IrModelFields(models.Model):
@api.constrains('domain')
def _check_domain(self):
for field in self:
safe_eval(field.domain or '[]')
try:
safe_eval(field.domain or '[]')
except ValueError as e:
raise ValidationError(
_("An error occurred while evaluating the domain:\n%(error)s", error=e)
) from e
@api.constrains('name')
def _check_name(self):
@ -1015,11 +1025,16 @@ class IrModelFields(models.Model):
if relation and not IrModel._get_id(relation):
raise UserError(_("Model %s does not exist!", vals['relation']))
if vals.get('ttype') == 'one2many' and not self.search_count([
('ttype', '=', 'many2one'),
('model', '=', vals['relation']),
('name', '=', vals['relation_field']),
]):
if (
vals.get('ttype') == 'one2many' and
vals.get("store", True) and
not vals.get("related") and
not self.search_count([
('ttype', '=', 'many2one'),
('model', '=', vals['relation']),
('name', '=', vals['relation_field']),
])
):
raise UserError(_("Many2one %(field)s on model %(model)s does not exist!", field=vals['relation_field'], model=vals['relation']))
if any(model in self.pool for model in models):
@ -1084,6 +1099,11 @@ class IrModelFields(models.Model):
_logger.warning("Deprecated since Odoo 19, ir.model.fields.translate becomes Selection, the value should be a string")
vals['translate'] = 'html_translate' if vals.get('ttype') == 'html' else 'standard'
if column_rename and self.state == 'manual':
# renaming a studio field, remove inherits fields
# we need to set the uninstall flag to allow removing them
(self._prepare_update() - self).with_context(**{MODULE_UNINSTALL_FLAG: True}).unlink()
res = super(IrModelFields, self).write(vals)
self.env.flush_all()
@ -1752,6 +1772,10 @@ class IrModelFieldsSelection(models.Model):
if not field or not field.store or not Model._auto:
continue
# Field changed its type, skip it.
if field.type not in ('selection', 'reference'):
continue
ondelete = (field.ondelete or {}).get(selection.value)
# special case for custom fields
if ondelete is None and field.manual and not field.required:
@ -1760,33 +1784,53 @@ class IrModelFieldsSelection(models.Model):
if ondelete is None:
# nothing to do, the selection does not come from a field extension
continue
elif callable(ondelete):
ondelete(selection._get_records())
elif ondelete == 'set null':
safe_write(selection._get_records(), field.name, False)
elif ondelete == 'set default':
value = field.convert_to_write(field.default(Model), Model)
safe_write(selection._get_records(), field.name, value)
elif ondelete.startswith('set '):
safe_write(selection._get_records(), field.name, ondelete[4:])
elif ondelete == 'cascade':
selection._get_records().unlink()
else:
# this shouldn't happen... simply a sanity check
raise ValueError(_(
'The ondelete policy "%(policy)s" is not valid for field "%(field)s"',
policy=ondelete, field=selection,
))
companies = self.env.companies if self.field_id.company_dependent else [self.env.company]
for company in companies:
# make a company-specific env for the Model and selection
Model = Model.with_company(company.id)
selection = selection.with_company(company.id)
if callable(ondelete):
ondelete(selection._get_records())
elif ondelete == 'set null':
safe_write(selection._get_records(), field.name, False)
elif ondelete == 'set default':
value = field.convert_to_write(field.default(Model), Model)
safe_write(selection._get_records(), field.name, value)
elif ondelete.startswith('set '):
safe_write(selection._get_records(), field.name, ondelete[4:])
elif ondelete == 'cascade':
selection._get_records().unlink()
else:
# this shouldn't happen... simply a sanity check
raise ValueError(_(
'The ondelete policy "%(policy)s" is not valid for field "%(field)s"',
policy=ondelete, field=selection,
))
def _get_records(self):
""" Return the records having 'self' as a value. """
self.ensure_one()
Model = self.env[self.field_id.model]
Model.flush_model([self.field_id.name])
query = 'SELECT id FROM "{table}" WHERE "{field}"=%s'.format(
table=Model._table, field=self.field_id.name,
)
self.env.cr.execute(query, [self.value])
if self.field_id.company_dependent:
# company-dependent fields are stored as jsonb (e.g; {company_id: value})
query = SQL(
"SELECT id FROM %s WHERE %s ->> %s = %s",
SQL.identifier(Model._table),
SQL.identifier(self.field_id.name),
str(self.env.company.id),
self.value,
)
else:
# normal selection fields are stored as general datatype
query = SQL(
"SELECT id FROM %s WHERE %s = %s",
SQL.identifier(Model._table),
SQL.identifier(self.field_id.name),
self.value,
)
self.env.cr.execute(query)
return Model.browse(r[0] for r in self.env.cr.fetchall())
@ -1848,6 +1892,7 @@ class IrModelConstraint(models.Model):
JOIN pg_class cl
ON (cs.conrelid = cl.oid)
WHERE cs.contype IN %s AND cs.conname = %s AND cl.relname = %s
AND cl.relnamespace = current_schema::regnamespace
""", ('c', 'u', 'x') if typ == 'u' else (typ,), hname, table
)):
self.env.execute_query(SQL(
@ -2511,7 +2556,12 @@ class IrModelData(models.Model):
# remove non-model records first, grouped by batches of the same model
for model, items in itertools.groupby(unique(records_items), itemgetter(0)):
delete(self.env[model].browse(item[1] for item in items))
ids = [item[1] for item in items]
# we cannot guarantee that the ir.model.data points to an existing model
if model in self.env:
delete(self.env[model].browse(ids))
else:
_logger.info("Orphan ir.model.data records %s refer to unavailable model '%s'", ids, model)
# Remove copied views. This must happen after removing all records from
# the modules to remove, otherwise ondelete='restrict' may prevent the

View file

@ -13,6 +13,7 @@ from docutils import nodes
from docutils.core import publish_string
from docutils.transforms import Transform, writer_aux
from docutils.writers.html4css1 import Writer
from markupsafe import Markup
import lxml.html
import psycopg2
@ -21,6 +22,7 @@ from odoo import api, fields, models, modules, tools, _
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
from odoo.exceptions import AccessDenied, UserError, ValidationError
from odoo.fields import Domain
from odoo.tools import config
from odoo.tools.parse_version import parse_version
from odoo.tools.misc import topological_sort, get_flag
from odoo.tools.translate import TranslationImporter, get_po_paths, get_datafile_translation_path
@ -122,7 +124,7 @@ class MyFilterMessages(Transform):
nodes_iter = self.document.traverse(nodes.system_message)
for node in nodes_iter:
_logger.warning("docutils' system message present: %s", str(node))
_logger.debug("docutils' system message present: %s", str(node))
node.parent.remove(node)
@ -196,7 +198,14 @@ class IrModuleModule(models.Model):
'xml_declaration': False,
'file_insertion_enabled': False,
}
output = publish_string(source=module.description if not module.application and module.description else '', settings_overrides=overrides, writer=MyWriter())
raw_description = module.description or ''
try:
output = publish_string(source=raw_description, settings_overrides=overrides, writer=MyWriter())
except Exception as e: # noqa: BLE001
_logger.warning("Failed to render module description for %s: %s. Falling back to raw description.", module.name, e)
output = Markup('<pre><code>%s</code></pre>') % raw_description
module.description_html = _apply_description_images(output)
@api.depends('name')
@ -355,7 +364,7 @@ class IrModuleModule(models.Model):
install_package = None
if platform.system() == 'Linux':
distro = platform.freedesktop_os_release()
id_likes = {distro['ID'], *distro.get('ID_LIKE').split()}
id_likes = {distro['ID'], *distro.get('ID_LIKE', '').split()}
if 'debian' in id_likes or 'ubuntu' in id_likes:
if package := manifest['external_dependencies'].get('apt', {}).get(e.dependency):
install_package = f'apt install {package}'
@ -417,7 +426,11 @@ class IrModuleModule(models.Model):
modules._state_update('to install', ['uninstalled'])
# Determine which auto-installable modules must be installed.
modules = self.search(auto_domain).filtered(must_install)
if config.get('skip_auto_install'):
modules = None
else:
modules = self.search(auto_domain).filtered(must_install)
# the modules that are installed/to install/to upgrade
install_mods = self.search([('state', 'in', list(install_states))])
@ -483,12 +496,12 @@ class IrModuleModule(models.Model):
def button_reset_state(self):
# reset the transient state for all modules in case the module operation is stopped in an unexpected way.
self.search([('state', '=', 'to install')]).state = 'uninstalled'
self.search([('state', 'in', ('to update', 'to remove'))]).state = 'installed'
self.search([('state', 'in', ('to upgrade', 'to remove'))]).state = 'installed'
return True
@api.model
def check_module_update(self):
return bool(self.sudo().search_count([('state', 'in', ('to install', 'to update', 'to remove'))], limit=1))
return bool(self.sudo().search_count([('state', 'in', ('to install', 'to upgrade', 'to remove'))], limit=1))
@assert_log_admin_access
def module_uninstall(self):

View file

@ -480,6 +480,10 @@ T_CALL_SLOT = '0'
ETREE_TEMPLATE_REF = count()
# Only allow a javascript scheme if it is followed by [ ][window.]history.back()
MALICIOUS_SCHEMES = re.compile(r'javascript:(?!( ?)((window\.)?)history\.back\(\)$)', re.I).findall
def _id_or_xmlid(ref):
try:
return int(ref)
@ -530,13 +534,14 @@ class QWebError(Exception):
class QWebErrorInfo:
def __init__(self, error: str, ref_name: str | int | None, ref: int | None, path: str | None, element: str | None, source: list[tuple[int | str, str, str]]):
def __init__(self, error: str, ref_name: str | int | None, ref: int | None, path: str | None, element: str | None, source: list[tuple[int | str, str, str]], surrounding: str):
self.error = error
self.template = ref_name
self.ref = ref
self.path = path
self.element = element
self.source = source
self.surrounding = surrounding
def __str__(self):
info = [self.error]
@ -551,6 +556,8 @@ class QWebErrorInfo:
if self.source:
source = '\n '.join(str(v) for v in self.source)
info.append(f'From: {source}')
if self.surrounding:
info.append(f'QWeb generated code:\n{self.surrounding}')
return '\n '.join(info)
@ -846,6 +853,8 @@ class IrQweb(models.AbstractModel):
raise
def _get_error_info(self, error, stack: list[QwebStackFrame], frame: QwebStackFrame) -> QWebErrorInfo:
no_id_ref = 'etree._Element'
path = None
html = None
loaded_codes = self.env.context['__qweb_loaded_codes']
@ -855,7 +864,7 @@ class IrQweb(models.AbstractModel):
options = self.env.context['__qweb_loaded_options'].get(frame.params.view_ref) or {}
ref = options.get('ref') or frame.params.view_ref # The template can have a null reference, for example for a provided etree.
ref_name = options.get('ref_name') or None
code = loaded_codes.get(frame.params.view_ref) or loaded_codes.get(False)
code = loaded_codes.get(frame.params.view_ref) or loaded_codes.get(no_id_ref)
if ref == self.env.context['_qweb_error_path_xml'][0]:
path = self.env.context['_qweb_error_path_xml'][1]
html = self.env.context['_qweb_error_path_xml'][2]
@ -864,23 +873,25 @@ class IrQweb(models.AbstractModel):
options = stack[-2].options or {} # The compilation may have failed before the compilation options were loaded.
ref = options.get('ref')
ref_name = options.get('ref_name')
code = loaded_codes.get(ref) or loaded_codes.get(False)
code = loaded_codes.get(ref) or loaded_codes.get(no_id_ref)
if frame.params.path_xml:
path = frame.params.path_xml[1]
html = frame.params.path_xml[2]
source_file_ref = None if ref == no_id_ref else ref
line_nb = 0
trace = traceback.format_exc()
for error_line in reversed(trace.split('\n')):
if f'File "<{ref}>"' in error_line or (ref is None and 'File "<' in error_line):
if f'File "<{source_file_ref}>"' in error_line or (ref is None and 'File "<' in error_line):
line_function = error_line.split(', line ')[1]
line_nb = int(line_function.split(',')[0])
break
source = [info.params.path_xml for info in stack if info.params.path_xml]
code_lines = (code or '').split('\n')
found = False
for code_line in reversed((code or '').split('\n')[:line_nb]):
for code_line in reversed(code_lines[:line_nb]):
if code_line.startswith('def '):
break
match = re.match(r'\s*# element: (.*) , (.*)', code_line)
@ -900,7 +911,24 @@ class IrQweb(models.AbstractModel):
if path:
source.append((ref, path, html))
return QWebErrorInfo(f'{error.__class__.__name__}: {error}', ref if ref_name is None else ref_name, ref, path, html, source)
surrounding = None
if self.env.context.get('dev_mode') and line_nb:
if html and ' t-if=' in html and ' if ' in '\n'.join(code_lines[line_nb - 2:line_nb - 1]):
line_nb -= 1
previous_lines = '\n'.join(code_lines[max(line_nb - 25, 0):line_nb - 1])
line = code_lines[line_nb - 1]
next_lines = '\n'.join(code_lines[line_nb:line_nb + 5])
indent = re.search(r"^(\s*)", line).group(0)
surrounding = textwrap.indent(
textwrap.dedent(
f"{previous_lines}\n"
f"{indent}########### Line triggering the error ############\n{line}\n"
f"{indent}##################################################\n{next_lines}"
),
' ' * 8
)
return QWebErrorInfo(f'{error.__class__.__name__}: {error}', ref if ref_name is None else ref_name, ref, path, html, source, surrounding)
# assume cache will be invalidated by third party on write to ir.ui.view
def _get_template_cache_keys(self):
@ -1680,7 +1708,7 @@ class IrQweb(models.AbstractModel):
""" Compile a purely static element into a list of string. """
if not el.nsmap:
unqualified_el_tag = el_tag = el.tag
attrib = self._post_processing_att(el.tag, el.attrib)
attrib = self._post_processing_att(el.tag, {**el.attrib, '__is_static_node': True})
else:
# Etree will remove the ns prefixes indirection by inlining the corresponding
# nsmap definition into the tag attribute. Restore the tag and prefix here.
@ -1711,7 +1739,7 @@ class IrQweb(models.AbstractModel):
else:
attrib[name] = value
attrib = self._post_processing_att(el.tag, attrib)
attrib = self._post_processing_att(el.tag, {**attrib, '__is_static_node': True})
# Update the dict of inherited namespaces before continuing the recursion. Note:
# since `compile_context['nsmap']` is a dict (and therefore mutable) and we do **not**
@ -2014,7 +2042,10 @@ class IrQweb(models.AbstractModel):
code.append(indent_code(f"values[{varname!r}] = {self._compile_format(exprf)}", level))
elif 't-valuef.translate' in el.attrib:
exprf = el.attrib.pop('t-valuef.translate')
code.append(indent_code(f"values[{varname!r}] = {self._compile_format(exprf)}", level))
if self.env.context.get('edit_translations'):
code.append(indent_code(f"values[{varname!r}] = Markup({self._compile_format(exprf)})", level))
else:
code.append(indent_code(f"values[{varname!r}] = {self._compile_format(exprf)}", level))
elif varname[0] == '{':
code.append(indent_code(f"values.update({self._compile_expr(varname)})", level))
else:
@ -2549,10 +2580,17 @@ class IrQweb(models.AbstractModel):
# args to values
for key in list(el.attrib):
if key.endswith(('.f', '.translate')):
name = key.removesuffix(".f").removesuffix(".translate")
if key.endswith('.f'):
name = key.removesuffix(".f")
value = el.attrib.pop(key)
code.append(indent_code(f"t_call_values[{name!r}] = {self._compile_format(value)}", level))
elif key.endswith('.translate'):
name = key.removesuffix(".f").removesuffix(".translate")
value = el.attrib.pop(key)
if self.env.context.get('edit_translations'):
code.append(indent_code(f"t_call_values[{name!r}] = Markup({self._compile_format(value)})", level))
else:
code.append(indent_code(f"t_call_values[{name!r}] = {self._compile_format(value)}", level))
elif not key.startswith('t-'):
value = el.attrib.pop(key)
code.append(indent_code(f"t_call_values[{key!r}] = {self._compile_expr(value)}", level))
@ -2668,6 +2706,8 @@ class IrQweb(models.AbstractModel):
@returns dict
"""
if not atts.pop('__is_static_node', False) and (href := atts.get('href')) and MALICIOUS_SCHEMES(str(href)):
atts['href'] = ""
return atts
def _get_field(self, record, field_name, expression, tagName, field_options, values):

View file

@ -3,6 +3,7 @@ import base64
import binascii
from datetime import time
import logging
import math
import re
from io import BytesIO
@ -217,33 +218,39 @@ class IrQwebFieldFloat(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
min_precision = options.get('min_precision')
if 'decimal_precision' in options:
precision = self.env['decimal.precision'].precision_get(options['decimal_precision'])
elif options.get('precision') is None:
int_digits = int(math.log10(abs(value))) + 1 if value != 0 else 1
max_dec_digits = max(15 - int_digits, 0)
# We display maximum 6 decimal digits or the number of significant decimal digits if it's lower
precision = min(6, max_dec_digits)
min_precision = min_precision or 1
else:
precision = options['precision']
if precision is None:
fmt = '%f'
else:
value = float_utils.float_round(value, precision_digits=precision)
fmt = '%.{precision}f'.format(precision=precision)
fmt = f'%.{precision}f'
if min_precision and min_precision < precision:
_, dec_part = float_utils.float_split_str(value, precision)
digits_count = len(dec_part.rstrip('0'))
if digits_count < min_precision:
fmt = f'%.{min_precision}f'
elif digits_count < precision:
fmt = f'%.{digits_count}f'
formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
# %f does not strip trailing zeroes. %g does but its precision causes
# it to switch to scientific notation starting at a million *and* to
# strip decimals. So use %f and if no precision was specified manually
# strip trailing 0.
if precision is None:
formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
return formatted
value = float_utils.float_round(value, precision_digits=precision)
return self.user_lang().format(fmt, value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
@api.model
def record_to_html(self, record, field_name, options):
field = record._fields[field_name]
if 'precision' not in options and 'decimal_precision' not in options:
_, precision = record._fields[field_name].get_digits(record.env) or (None, None)
_, precision = field.get_digits(record.env) or (None, None)
options = dict(options, precision=precision)
if 'min_precision' not in options and hasattr(field, 'get_min_display_digits'):
min_precision = field.get_min_display_digits(record.env)
options = dict(options, min_precision=min_precision)
return super().record_to_html(record, field_name, options)
@ -356,7 +363,7 @@ class IrQwebFieldSelection(models.AbstractModel):
def value_to_html(self, value, options):
if not value:
return ''
return escape(options['selection'][value] or '')
return escape(options['selection'].get(value, value) or '')
@api.model
def record_to_html(self, record, field_name, options):
@ -393,6 +400,19 @@ class IrQwebFieldMany2many(models.AbstractModel):
return nl2br(text)
class IrQwebFieldOne2many(models.AbstractModel):
_name = 'ir.qweb.field.one2many'
_description = 'Qweb field one2many'
_inherit = ['ir.qweb.field']
@api.model
def value_to_html(self, value, options):
if not value:
return False
text = ', '.join(value.sudo().mapped('display_name'))
return nl2br(text)
class IrQwebFieldHtml(models.AbstractModel):
_name = 'ir.qweb.field.html'
_description = 'Qweb Field HTML'

View file

@ -32,7 +32,12 @@ def _alter_sequence(cr, seq_name, number_increment=None, number_next=None):
""" Alter a PostreSQL sequence. """
if number_increment == 0:
raise UserError(_("Step must not be zero."))
cr.execute("SELECT relname FROM pg_class WHERE relkind=%s AND relname=%s", ('S', seq_name))
cr.execute(
"SELECT relname FROM pg_class"
" WHERE relkind = %s AND relname = %s"
" AND relnamespace = current_schema::regnamespace",
('S', seq_name)
)
if not cr.fetchone():
# sequence is not created yet, we're inside create() so ignore it, will be set later
return

View file

@ -216,8 +216,8 @@ actual arch.
return re.sub(r'(?P<prefix>[^%])%\((?P<xmlid>.*?)\)[ds]', replacer, arch_fs)
lang = self.env.lang or 'en_US'
env_en = self.with_context(edit_translations=None, lang='en_US').env
env_lang = self.with_context(lang=lang).env
env_en = self.with_context(edit_translations=None, lang='en_US', check_translations=True).env
env_lang = self.with_context(lang=lang, check_translations=True).env
field_arch_db = self._fields['arch_db']
for view in self:
arch_fs = None
@ -246,6 +246,7 @@ actual arch.
def _inverse_arch(self):
for view in self:
self._validate_xml_encoding(view.arch)
data = dict(arch_db=view.arch)
if 'install_filename' in self.env.context:
# we store the relative path to the resource instead of the absolute path, if found
@ -272,6 +273,7 @@ actual arch.
def _inverse_arch_base(self):
for view, view_wo_lang in zip(self, self.with_context(lang=None)):
self._validate_xml_encoding(view.arch_base)
view_wo_lang.arch = view.arch_base
def reset_arch(self, mode='soft'):
@ -320,6 +322,7 @@ actual arch.
@api.depends('arch', 'inherit_id')
def _compute_invalid_locators(self):
def assess_locator(source, spec):
node = None
with suppress(ValidationError): # Syntax error
# If locate_node returns None here:
# Invalid expression: Ok Syntax, but cannot be anchored to the parent view.
@ -439,7 +442,8 @@ actual arch.
combined_arch = view._get_combined_arch()
# check primary view that extends this current view
if view.inherit_id or view.inherit_children_ids:
# keep a way to skip this check to avoid marking too many views as failed during an upgrade
if not self.env.context.get('_skip_primary_extensions_check') and (view.inherit_id or view.inherit_children_ids):
root = view
while root.inherit_id and root.mode != 'primary':
root = root.inherit_id
@ -591,8 +595,6 @@ actual arch.
# delete empty arch_db to avoid triggering _check_xml before _inverse_arch_base is called
del values['arch_db']
if values.get('arch_base'):
self._validate_xml_encoding(values['arch_base'])
if not values.get('type'):
if values.get('inherit_id'):
values['type'] = self.browse(values['inherit_id']).type
@ -609,7 +611,7 @@ actual arch.
"Allowed types are: %(valid_types)s",
view_type=values['type'], valid_types=', '.join(valid_types)
))
except LxmlError:
except (etree.ParseError, ValueError):
# don't raise here, the constraint that runs `self._check_xml` will
# do the job properly.
pass
@ -652,8 +654,6 @@ actual arch.
if 'arch_db' in vals and not self.env.context.get('no_save_prev'):
vals['arch_prev'] = self.arch_db
if vals.get('arch_base'):
self._validate_xml_encoding(vals['arch_base'])
res = super().write(self._compute_defaults(vals))
# Check the xml of the view if it gets re-activated or changed.
@ -692,8 +692,11 @@ actual arch.
:return: id of the default view of False if none found
:rtype: int
"""
domain = [('model', '=', model), ('type', '=', view_type), ('mode', '=', 'primary')]
return self.search(domain, limit=1).id
return self.search(self._get_default_view_domain(model, view_type), limit=1).id
@api.model
def _get_default_view_domain(self, model, view_type):
return Domain([('model', '=', model), ('type', '=', view_type), ('mode', '=', 'primary')])
#------------------------------------------------------
# Inheritance mecanism
@ -1331,7 +1334,15 @@ actual arch.
# check the read/visibility access
for node in tree.xpath('//*[@__groups_key__]'):
if not has_access(node.attrib.pop('__groups_key__')):
node.getparent().remove(node)
tail = node.tail
parent = node.getparent()
previous = node.getprevious()
parent.remove(node)
if tail:
if previous is not None:
previous.tail = (previous.tail or '') + tail
elif parent is not None:
parent.text = (parent.text or '') + tail
elif node.tag == 't' and not node.attrib:
# Move content of <t groups=""> blocks
# and remove the <t> node.
@ -3197,9 +3208,9 @@ class Base(models.AbstractModel):
:rtype: list
"""
return [
'change_default', 'context', 'currency_field', 'definition_record', 'definition_record_field', 'digits', 'domain', 'aggregator', 'groups',
'help', 'model_field', 'name', 'readonly', 'related', 'relation', 'relation_field', 'required', 'searchable', 'selection', 'size',
'sortable', 'store', 'string', 'translate', 'trim', 'type', 'groupable', 'falsy_value_label'
'change_default', 'context', 'currency_field', 'definition_record', 'definition_record_field', 'digits', 'min_display_digits', 'domain',
'aggregator', 'groups', 'help', 'model_field', 'name', 'readonly', 'related', 'relation', 'relation_field', 'required', 'searchable',
'selection', 'size', 'sortable', 'store', 'string', 'translate', 'trim', 'type', 'groupable', 'falsy_value_label'
]
@api.readonly

View file

@ -3,7 +3,8 @@ import re
from collections.abc import Iterable
from odoo import api, fields, models
from odoo.tools import _, SQL
from odoo.exceptions import UserError
from odoo.tools import _, clean_context
def sanitize_account_number(acc_number):
@ -123,7 +124,7 @@ class ResPartnerBank(models.Model):
for bank in self:
bank.acc_type = self.retrieve_acc_type(bank.acc_number)
@api.depends('partner_id.name')
@api.depends('partner_id')
def _compute_account_holder_name(self):
for bank in self:
bank.acc_holder_name = bank.partner_id.name
@ -144,6 +145,22 @@ class ResPartnerBank(models.Model):
for bank in self:
bank.color = 10 if bank.allow_out_payment else 1
def _sanitize_vals(self, vals):
if 'sanitized_acc_number' in vals: # do not allow to write on sanitized directly
vals['acc_number'] = vals.pop('sanitized_acc_number')
if 'acc_number' in vals:
vals['sanitized_acc_number'] = sanitize_account_number(vals['acc_number'])
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._sanitize_vals(vals)
return super().create(vals_list)
def write(self, vals):
self._sanitize_vals(vals)
return super().write(vals)
def action_archive_bank(self):
"""
Custom archive function because the basic action_archive don't trigger a re-rendering of the page, so
@ -160,3 +177,47 @@ class ResPartnerBank(models.Model):
"""
self.action_archive()
return True
def _user_can_trust(self):
self.ensure_one()
return True
def _find_or_create_bank_account(self, account_number, partner, company, *, allow_company_account_creation=False, extra_create_vals=None):
"""Find a bank account for the given partner and number. Create it if it doesn't exist.
Manage different corner cases:
- make sure that we don't try to create the bank number if we look for it but it exists restricted in another
company; because of the unique constraint
- make sure that we don't create a bank account number for one of the database's companies, unless
`allow_company_account_creation` is specified
:param account_number: the bank account number to search for (or to create)
:param partner: the partner linked to the account number
:param company: the company that the bank needs to be accessible from (only for searching)
:param allow_company_account_creation: whether we disable the protection to create an account for our own
companies
:param extra_create_vals: values to be added when creating the account, but not to write if the account was
found and e.g. modified manually beforehands
"""
bank_account = self.env['res.partner.bank'].sudo().with_context(active_test=False).search([
('acc_number', '=', account_number),
('partner_id', 'child_of', partner.commercial_partner_id.id),
])
if not bank_account:
if not allow_company_account_creation and partner.id in self.env['res.company']._get_company_partner_ids():
raise UserError(_(
"Please add your own bank account manually: %(account_number)s (%(partner)s)",
account_number=account_number,
partner=partner.display_name,
))
bank_account = self.env['res.partner.bank'].with_context(clean_context(self.env.context)).create({
**(extra_create_vals or {}),
'acc_number': account_number,
'partner_id': partner.id,
'allow_out_payment': False,
})
return bank_account.filtered_domain([
*self.env['res.partner.bank']._check_company_domain(company),
('active', '=', True),
]).sorted(lambda b: b.partner_id != partner).sudo(False)[:1]

View file

@ -374,8 +374,12 @@ class ResCompany(models.Model):
('id', 'child_of', company.id),
('id', '!=', company.id),
])
for fname in sorted(changed):
branches[fname] = company[fname]
changed_vals = {
fname: self._fields[fname].convert_to_write(company[fname], branches)
for fname in sorted(changed)
}
branches.write(changed_vals)
if companies_needs_l10n:
companies_needs_l10n.install_l10n_modules()
@ -483,3 +487,7 @@ class ResCompany(models.Model):
'company_id': self.id,
'company_ids': [(6, 0, [self.id])],
})
@ormcache()
def _get_company_partner_ids(self):
return tuple(self.env['res.company'].sudo().with_context(active_test=False).search([]).partner_id.ids)

View file

@ -20,6 +20,7 @@ FLAG_MAPPING = {
"RE": "fr",
"MF": "fr",
"UM": "us",
"XI": "uk",
}
NO_FLAG_COUNTRIES = [

View file

@ -1,11 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import nullcontext
from datetime import datetime
from datetime import datetime, timedelta
import logging
from odoo import api, fields, models, tools
from odoo.http import GeoIP, request, root, STORED_SESSION_BYTES
from odoo.http import GeoIP, get_session_max_inactivity, request, root, STORED_SESSION_BYTES
from odoo.tools import SQL, OrderedSet, unique
from odoo.tools.translate import _
from .res_users import check_identity
@ -137,24 +137,34 @@ class ResDeviceLog(models.Model):
Set the field ``revoked`` to ``True`` for ``res.device.log``
for which the session file no longer exists on the filesystem.
"""
device_logs_by_session_identifier = {}
for session_identifier, device_logs in self.env['res.device.log']._read_group(
domain=[('revoked', '=', False)],
groupby=['session_identifier'],
aggregates=['id:recordset'],
):
device_logs_by_session_identifier[session_identifier] = device_logs
batch_size = 100_000
offset = 0
revoked_session_identifiers = root.session_store.get_missing_session_identifiers(
device_logs_by_session_identifier.keys()
)
device_logs_to_revoke = self.env['res.device.log'].concat(*map(
device_logs_by_session_identifier.get,
revoked_session_identifiers
))
# Initial run may take 5-10 minutes due to many non-revoked sessions,
# marking them enables index use on ``revoked IS NOT TRUE``.
device_logs_to_revoke.sudo().write({'revoked': True})
while True:
candidate_device_log_ids = self.env['res.device.log'].search_fetch(
[
('revoked', '=', False),
('last_activity', '<', datetime.now() - timedelta(seconds=get_session_max_inactivity(self.env))),
],
['session_identifier'],
order='id',
limit=batch_size,
offset=offset,
)
if not candidate_device_log_ids:
break
offset += batch_size
revoked_session_identifiers = root.session_store.get_missing_session_identifiers(
set(candidate_device_log_ids.mapped('session_identifier'))
)
if not revoked_session_identifiers:
continue
to_revoke = candidate_device_log_ids.filtered(
lambda candidate: candidate.session_identifier in revoked_session_identifiers
)
to_revoke.write({'revoked': True})
self.env.cr.commit()
offset -= len(to_revoke)
class ResDevice(models.Model):

View file

@ -319,7 +319,7 @@ class ResGroups(models.Model):
self.view_group_hierarchy = self._get_view_group_hierarchy()
@api.model
@tools.ormcache(cache='groups')
@tools.ormcache('self.env.lang', cache='groups')
def _get_view_group_hierarchy(self):
return {
'groups': {

View file

@ -64,6 +64,9 @@ class ResLang(models.Model):
('%d-%m-%Y', '31-01-%s' % current_year),
('%m-%d-%Y', '01-31-%s' % current_year),
('%Y-%m-%d', '%s-01-31' % current_year),
('%d.%m.%Y', '31.01.%s' % current_year),
('%m.%d.%Y', '01.31.%s' % current_year),
('%Y.%m.%d', '%s.01.31' % current_year),
]
name = fields.Char(required=True)
@ -165,6 +168,16 @@ class ResLang(models.Model):
lang.active = True
return lang
def _activate_and_install_lang(self, code):
""" Activate languages and update their translations
:param code: code of the language to activate
:return: the language matching 'code' activated
"""
lang = self.with_context(active_test=False).search([('code', '=', code)])
if lang and not lang.active:
lang.action_unarchive()
return lang
def _create_lang(self, lang, lang_name=None):
""" Create the given language and make it active. """
# create the language with locale information

View file

@ -188,6 +188,7 @@ class ResPartner(models.Model):
_order = "complete_name ASC, id DESC"
_rec_names_search = ['complete_name', 'email', 'ref', 'vat', 'company_registry'] # TODO vat must be sanitized the same way for storing/searching
_allow_sudo_commands = False
_check_company_auto = True
_check_company_domain = models.check_company_domain_parent_of
# the partner types that must be added to a partner's complete name, like "Delivery"
@ -865,6 +866,11 @@ class ResPartner(models.Model):
vals['website'] = self._clean_website(vals['website'])
if vals.get('parent_id'):
vals['company_name'] = False
if vals.get('name'):
for partner in self:
for bank in partner.bank_ids:
if bank.acc_holder_name == partner.name:
bank.acc_holder_name = vals['name']
# filter to keep only really updated values -> field synchronize goes through
# partner tree and we should avoid infinite loops in case same value is
@ -896,9 +902,9 @@ class ResPartner(models.Model):
del vals['is_company']
result = result and super().write(vals)
for partner, pre_values in zip(self, pre_values_list, strict=True):
if any(u._is_internal() for u in partner.user_ids if u != self.env.user):
self.env['res.users'].check_access('write')
updated = {fname: fvalue for fname, fvalue in vals.items() if partner[fname] != pre_values[fname]}
if internal_users := partner.user_ids.filtered(lambda u: u._is_internal() and u != self.env.user):
internal_users.check_access('write')
updated = {fname: fvalue for fname, fvalue in vals.items() if partner[fname] != pre_values.get(fname)}
if updated:
partner._fields_sync(updated)
return result
@ -923,6 +929,7 @@ class ResPartner(models.Model):
return partners
for partner, vals in zip(partners, vals_list):
vals = self.env['res.partner']._add_missing_default_values(vals)
partner._fields_sync(vals)
return partners
@ -982,11 +989,7 @@ class ResPartner(models.Model):
def create_company(self):
self.ensure_one()
if self.company_name:
# Create parent company
values = dict(name=self.company_name, is_company=True, vat=self.vat)
values.update(self._convert_fields_to_values(self._address_fields()))
new_company = self.create(values)
if (new_company := self._create_contact_parent_company()):
# Set new company as my parent
self.write({
'parent_id': new_company.id,
@ -994,6 +997,15 @@ class ResPartner(models.Model):
})
return True
def _create_contact_parent_company(self):
self.ensure_one()
if self.company_name:
# Create parent company
values = dict(name=self.company_name, is_company=True, vat=self.vat)
values.update(self._convert_fields_to_values(self._address_fields()))
return self.create(values)
return self.browse()
def open_commercial_entity(self):
""" Utility method used to add an "Open Company" button in partner views """
self.ensure_one()

View file

@ -25,7 +25,7 @@ from odoo.api import SUPERUSER_ID
from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationError
from odoo.fields import Command, Domain
from odoo.http import request, DEFAULT_LANG
from odoo.tools import email_domain_extract, is_html_empty, frozendict, reset_cached_properties, SQL
from odoo.tools import email_domain_extract, is_html_empty, frozendict, reset_cached_properties, str2bool, SQL
_logger = logging.getLogger(__name__)
@ -268,7 +268,7 @@ class ResUsers(models.Model):
def _default_view_group_hierarchy(self):
return self.env['res.groups']._get_view_group_hierarchy()
view_group_hierarchy = fields.Json(string='Technical field for user group setting', store=False, default=_default_view_group_hierarchy)
view_group_hierarchy = fields.Json(string='Technical field for user group setting', store=False, copy=False, default=_default_view_group_hierarchy)
role = fields.Selection([('group_user', 'User'), ('group_system', 'Administrator')], compute='_compute_role', readonly=False, string="Role")
_login_key = models.Constraint("UNIQUE (login)",
@ -454,7 +454,7 @@ class ResUsers(models.Model):
@api.depends('name')
def _compute_signature(self):
for user in self.filtered(lambda user: user.name and is_html_empty(user.signature)):
user.signature = Markup('<p>%s</p>') % user['name']
user.signature = Markup('<div>%s</div>') % user['name']
@api.depends('all_group_ids')
def _compute_share(self):
@ -647,6 +647,7 @@ class ResUsers(models.Model):
@api.ondelete(at_uninstall=True)
def _unlink_except_master_data(self):
portal_user_template = self.env.ref('base.template_portal_user_id', False)
public_user = self.env.ref('base.public_user', False)
if SUPERUSER_ID in self.ids:
raise UserError(_('You can not remove the admin user as it is used internally for resources created by Odoo (updates, module installation, ...)'))
user_admin = self.env.ref('base.user_admin', raise_if_not_found=False)
@ -655,6 +656,8 @@ class ResUsers(models.Model):
self.env.registry.clear_cache()
if portal_user_template and portal_user_template in self:
raise UserError(_('Deleting the template users is not allowed. Deleting this profile will compromise critical functionalities.'))
if public_user and public_user in self:
raise UserError(_("Deleting the public user is not allowed. Deleting this profile will compromise critical functionalities."))
@api.model
def name_search(self, name='', domain=None, operator='ilike', limit=100):
@ -1356,9 +1359,10 @@ class UsersMultiCompany(models.Model):
'base.group_multi_company', raise_if_not_found=False)
if group_multi_company_id:
for user in users:
if len(user.company_ids) <= 1 and group_multi_company_id in user.group_ids.ids:
company_count = len(user.sudo().company_ids)
if company_count <= 1 and group_multi_company_id in user.group_ids.ids:
user.write({'group_ids': [Command.unlink(group_multi_company_id)]})
elif len(user.company_ids) > 1 and group_multi_company_id not in user.group_ids.ids:
elif company_count > 1 and group_multi_company_id not in user.group_ids.ids:
user.write({'group_ids': [Command.link(group_multi_company_id)]})
return users
@ -1370,9 +1374,10 @@ class UsersMultiCompany(models.Model):
'base.group_multi_company', raise_if_not_found=False)
if group_multi_company_id:
for user in self:
if len(user.company_ids) <= 1 and group_multi_company_id in user.group_ids.ids:
company_count = len(user.sudo().company_ids)
if company_count <= 1 and group_multi_company_id in user.group_ids.ids:
user.write({'group_ids': [Command.unlink(group_multi_company_id)]})
elif len(user.company_ids) > 1 and group_multi_company_id not in user.group_ids.ids:
elif company_count > 1 and group_multi_company_id not in user.group_ids.ids:
user.write({'group_ids': [Command.link(group_multi_company_id)]})
return res
@ -1384,9 +1389,10 @@ class UsersMultiCompany(models.Model):
group_multi_company_id = self.env['ir.model.data']._xmlid_to_res_id(
'base.group_multi_company', raise_if_not_found=False)
if group_multi_company_id:
if len(user.company_ids) <= 1 and group_multi_company_id in user.group_ids.ids:
company_count = len(user.sudo().company_ids)
if company_count <= 1 and group_multi_company_id in user.group_ids.ids:
user.update({'group_ids': [Command.unlink(group_multi_company_id)]})
elif len(user.company_ids) > 1 and group_multi_company_id not in user.group_ids.ids:
elif company_count > 1 and group_multi_company_id not in user.group_ids.ids:
user.update({'group_ids': [Command.link(group_multi_company_id)]})
return user
@ -1507,6 +1513,7 @@ KEY_CRYPT_CONTEXT = CryptContext(
# attacks on API keys isn't much of a concern
['pbkdf2_sha512'], pbkdf2_sha512__rounds=6000,
)
DEFAULT_PROGRAMMATIC_API_KEYS_LIMIT = 10 # programmatic API key creation is refused if the user already has at least this amount of API keys
class ResUsersApikeys(models.Model):
@ -1560,6 +1567,7 @@ class ResUsersApikeys(models.Model):
_logger.info("API key(s) removed: scope: <%s> for '%s' (#%s) from %s",
self.mapped('scope'), self.env.user.login, self.env.uid, ip)
self.sudo().unlink()
self.env.registry.clear_cache()
return {'type': 'ir.actions.act_window_close'}
raise AccessError(_("You can not remove API keys unless they're yours or you are a system user"))
@ -1622,6 +1630,89 @@ class ResUsersApikeys(models.Model):
return k
def _ensure_can_manage_keys_programmatically(self):
# Administrators would not be restricted by the ICP check alone,
# as they could temporarily enable the setting via set_param().
# However, this is considered bad practice because it would create a time window
# where anyone could manage API keys programmatically.
# Additionally, the enable / call / restore process involves three distinct calls,
# which is not atomic and prone to errors (e.g., server unavailability during restore),
# potentially leaving the configuration enabled for all users.
# To avoid this, an exception is made for Administrators.
# However, if programmatic API key management were to be enabled by default,
# this exception should be removed, as disabling the feature should be global.
ICP = self.env['ir.config_parameter'].sudo()
programmatic_api_keys_enabled = str2bool(ICP.get_param('base.enable_programmatic_api_keys'), False)
if not (self.env.is_system() or programmatic_api_keys_enabled):
raise UserError(_("Programmatic API keys are not enabled"))
@api.model
def generate(self, key, scope, name, expiration_date):
"""
Generate a new API key with an existing API key.
The provided `key` must be an existing API key that belongs to the current user.
Its scope must be compatible with `scope`.
The `expiration_date` must be allowed for the user's group.
To renew a key, generate the new one, store it, and then call `revoke` on the previous one.
"""
self._ensure_can_manage_keys_programmatically()
with self.env['res.users']._assert_can_auth(user=key[:INDEX_SIZE]):
if not isinstance(expiration_date, datetime.datetime):
expiration_date = fields.Datetime.from_string(expiration_date)
nb_keys = self.search_count([('user_id', '=', self.env.uid),
'|', ('expiration_date', '=', False), ('expiration_date', '>=', self.env.cr.now())])
try:
ICP = self.env['ir.config_parameter'].sudo()
nb_keys_limit = int(ICP.get_param('base.programmatic_api_keys_limit', DEFAULT_PROGRAMMATIC_API_KEYS_LIMIT))
except ValueError:
_logger.warning("Invalid value for 'base.programmatic_api_keys_limit', using default value.")
nb_keys_limit = DEFAULT_PROGRAMMATIC_API_KEYS_LIMIT
if nb_keys >= nb_keys_limit:
raise UserError(_('Limit of %s API keys is reached for programmatic creation', nb_keys_limit))
# Scope compatibility rules:
# - A global key can generate credentials for any scope (including global).
# - A scoped key can only generate credentials for its own scope.
#
# This is enforced in _check_credentials by validating scope usage,
# and the validated scope is then reused when calling _generate.
uid = self.env['res.users.apikeys']._check_credentials(scope=scope or 'rpc', key=key)
if not uid or uid != self.env.uid:
raise AccessDenied(_("The provided API key is invalid or does not belong to the current user."))
new_key = self._generate(scope, name, expiration_date)
_logger.info("%s %r generated from %r", self._description, new_key[:INDEX_SIZE], key[:INDEX_SIZE])
return new_key
@api.model
def revoke(self, key):
"""
Revoke an existing API key.
If it exists, the `key` will be removed from the server.
"""
self._ensure_can_manage_keys_programmatically()
assert key, "key required"
with self.env['res.users']._assert_can_auth(user=key[:INDEX_SIZE]):
self.env.cr.execute(SQL('''
SELECT id, key
FROM %(table)s
WHERE
index = %(index)s
AND (
expiration_date IS NULL OR
expiration_date >= now() at time zone 'utc'
)
''', table=SQL.identifier(self._table), index=key[:INDEX_SIZE]))
for key_id, current_key in self.env.cr.fetchall():
if key and KEY_CRYPT_CONTEXT.verify(key, current_key):
self.env['res.users.apikeys'].browse(key_id)._remove()
return True
raise AccessDenied(_("The provided API key is invalid."))
@api.autovacuum
def _gc_user_apikeys(self):
self.env.cr.execute(SQL("""

View file

@ -196,6 +196,7 @@
<rng:optional><rng:attribute name="domain_filter"/></rng:optional>
<rng:optional><rng:attribute name="class"/></rng:optional>
<rng:optional><rng:attribute name="string"/></rng:optional>
<rng:optional><rng:attribute name="help"/></rng:optional>
<rng:optional><rng:attribute name="completion"/></rng:optional>
<rng:optional><rng:attribute name="width"/></rng:optional>
<rng:optional><rng:attribute name="type"/></rng:optional>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

View file

@ -24,6 +24,7 @@ from . import test_install
from . import test_avatar_mixin
from . import test_init
from . import test_ir_actions
from . import test_ir_asset
from . import test_ir_attachment
from . import test_ir_cron
from . import test_ir_filters

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<asset id="base.test_asset_tag_aaa" name="Test asset tag (init active => keep active => update active)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_aii" name="Test asset tag (init active => make inactive => update inactive)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_aia" name="Test asset tag (init active => make inactive => update active)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
<field name="active">True</field> <!-- Take into account during update -->
</asset>
<asset id="base.test_asset_tag_iii" name="Test asset tag (init inactive => keep inactive => update inactive)" active="False">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_iaa" name="Test asset tag (init inactive => make active => update active)" active="False">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_prepend" name="Test asset tag with directive">
<bundle directive="prepend">test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_extra" name="Test asset tag with extra field">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
<field name="sequence" eval="17"/>
</asset>
</odoo>

View file

@ -89,7 +89,7 @@ class BaseCommon(TransactionCase):
@classmethod
def get_default_groups(cls):
return cls.env['res.users']._default_groups()
return cls.env.ref('base.group_user')
@classmethod
def setup_main_company(cls, currency_code='USD'):
@ -256,7 +256,7 @@ class SavepointCaseWithUserDemo(TransactionCase):
'name': 'Austin Kennedy', # Tom Ruiz
})],
}, {
'name': 'Pepper Street', # 'Deco Addict',
'name': 'Pepper Street', # 'Acme Corporation',
'state_id': cls.env.ref('base.state_us_2').id,
'child_ids': [Command.create({
'name': 'Liam King', # 'Douglas Fletcher',

View file

@ -49,6 +49,7 @@ reportgz = False
screencasts =
screenshots = /tmp/odoo_tests
server_wide_modules = base,rpc,web
skip_auto_install = False
smtp_password =
smtp_port = 25
smtp_server = localhost

View file

@ -159,6 +159,32 @@ class TestParentStore(TransactionCase):
self.assertEqual(len(old_struct), 4, "After duplication, previous record must have old childs records only")
self.assertFalse(new_struct & old_struct, "After duplication, nodes should not be mixed")
def test_missing_parent(self):
""" Missing parent id should not raise an error. """
# Missing parent with _parent_store
new_cat0 = self.cat0.copy()
records = new_cat0.search([('parent_id', 'parent_of', 999999999)])
self.assertEqual(len(records), 0)
# Missing parent without _parent_store
category = self.env['res.partner.category']
self.patch(self.env.registry['res.partner.category'], '_parent_store', False)
records = category.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
def test_missing_child(self):
""" Missing child id should not raise an error. """
# Missing child with _parent_store
new_cat0 = self.cat0.copy()
records = new_cat0.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
# Missing child without _parent_store
category = self.env['res.partner.category']
self.patch(self.env.registry['res.partner.category'], '_parent_store', False)
records = category.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
def test_duplicate_children_01(self):
""" Duplicate the children then reassign them to the new parent (1st method). """
new_cat1 = self.cat1.copy()

View file

@ -1,15 +1,12 @@
import io
import os
import re
import subprocess as sp
import sys
import textwrap
import time
import unittest
from pathlib import Path
from odoo.cli.command import commands, load_addons_commands, load_internal_commands
from odoo.tests import BaseCase, TransactionCase
from odoo.tests import BaseCase
from odoo.tools import config, file_path
@ -132,55 +129,3 @@ class TestCommand(BaseCase):
# we skip local variables as they differ based on configuration (e.g.: if a database is specified or not)
lines = [line for line in shell.stdout.read().splitlines() if line.startswith('>>>')]
self.assertEqual(lines, [">>> Hello from Python!", '>>> '])
class TestCommandUsingDb(TestCommand, TransactionCase):
@unittest.skipIf(
os.name != 'posix' and sys.version_info < (3, 12),
"os.set_blocking on files only available in windows starting 3.12",
)
def test_i18n_export(self):
# i18n export is a process that takes a long time to run, we are
# not interrested in running it in full, we are only interrested
# in making sure it starts correctly.
#
# This test only asserts the first few lines and then SIGTERM
# the process. We took the challenge to write a cross-platform
# test, the lack of a select-like API for Windows makes the code
# a bit complicated. Sorry :/
expected_text = textwrap.dedent("""\
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# \t* base
""").encode()
proc = self.popen_command(
'i18n', 'export', '-d', self.env.cr.dbname, '-o', '-', 'base',
# ensure we get a io.FileIO and not a buffered or text shit
text=False, bufsize=0,
)
# Feed the buffer for maximum 15 seconds.
buffer = io.BytesIO()
timeout = time.monotonic() + 15
os.set_blocking(proc.stdout.fileno(), False)
while buffer.tell() < len(expected_text) and time.monotonic() < timeout:
if chunk := proc.stdout.read(len(expected_text) - buffer.tell()):
buffer.write(chunk)
else:
# would had loved to use select() for its timeout, but
# select doesn't work on files on windows, use a flat
# sleep instead: not great, not terrible.
time.sleep(.1)
proc.terminate()
try:
proc.wait(timeout=5)
except sp.TimeoutExpired:
proc.kill()
raise
self.assertEqual(buffer.getvalue(), expected_text,
"The subprocess did not write the prelude in under 15 seconds.")

View file

@ -152,6 +152,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': [],
'skip_auto_install': False,
'stop_after_init': False,
'osv_memory_count_limit': 0,
'transient_age_limit': 1.0,
@ -270,6 +271,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': ['xml'], # blacklist for save, read from the config file
'stop_after_init': False,
'skip_auto_install': False,
'osv_memory_count_limit': 71,
'transient_age_limit': 4.0,
'max_cron_threads': 4,
@ -390,6 +392,7 @@ class TestConfigManager(TransactionCase):
'init': {},
'publisher_warranty_url': 'http://services.odoo.com/publisher-warranty/',
'save': False,
'skip_auto_install': False,
'stop_after_init': False,
# undocummented options
@ -570,6 +573,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': ['xml', 'reload'],
'skip_auto_install': False,
'stop_after_init': True,
'osv_memory_count_limit': 71,
'transient_age_limit': 4.0,
@ -629,6 +633,7 @@ class TestConfigManager(TransactionCase):
'upgrade_path': [],
'pre_upgrade_scripts': [],
'server_wide_modules': ['web', 'base', 'mail'],
'skip_auto_install': False,
'data_dir': '/tmp/data-dir',
# HTTP

View file

@ -1236,14 +1236,14 @@ class TestExpression(SavepointCaseWithUserDemo, TransactionExpressionCase):
# indirect search via m2o
Partner = self.env['res.partner']
deco_addict = self._search(Partner, [('name', '=', 'Pepper Street')])
acme_corp = self._search(Partner, [('name', '=', 'Pepper Street')])
not_be = self._search(Partner, [('country_id', '!=', 'Belgium')])
self.assertNotIn(deco_addict, not_be)
self.assertNotIn(acme_corp, not_be)
Partner = Partner.with_context(lang='fr_FR')
not_be = self._search(Partner, [('country_id', '!=', 'Belgique')])
self.assertNotIn(deco_addict, not_be)
self.assertNotIn(acme_corp, not_be)
def test_or_with_implicit_and(self):
# Check that when using expression.OR on a list of domains with at least one
@ -1793,36 +1793,78 @@ class TestQueries(TransactionCase):
''']):
Model.search([])
@mute_logger('odoo.models.unlink')
def test_access_rules_active_test(self):
Model = self.env['res.partner'].with_user(self.env.ref('base.user_admin'))
self.env['ir.rule'].search([]).unlink()
PartnerCateg = self.env['res.partner.category']
model_id = self.env['ir.model']._get('res.partner.category').id
self.env['ir.rule'].search([('model_id', '=', model_id)]).unlink()
self.env['ir.rule'].create([{
'name': 'partner users rule',
'model_id': self.env['ir.model']._get('res.partner').id,
'domain_force': str([('user_ids.login', 'like', '%@%')]),
'name': 'categ childs rule',
'model_id': model_id,
'domain_force': str([('child_ids', 'not any', [('name', 'ilike', 'private')])]),
}, {
'name': 'partners rule',
'model_id': self.env['ir.model']._get('res.partner').id,
'domain_force': str([('commercial_partner_id.name', '=', 'John')]),
'name': 'categ rule',
'model_id': model_id,
'domain_force': str([('parent_id.name', 'ilike', 'public')]),
}])
Model.search([])
pub_active, pub_inactive, pri_active, pri_inactive = PartnerCateg.create([
{'name': 'public active', 'active': True},
{'name': 'public inactive', 'active': False},
{'name': 'private active', 'active': True},
{'name': 'private inactive', 'active': False},
])
accessible_records = PartnerCateg.create([
{'name': 'a1', 'parent_id': pub_active.id},
{'name': 'a2', 'parent_id': pub_inactive.id},
{'name': 'a3', 'parent_id': pub_active.id, 'child_ids': [Command.create({'name': 'not PRI'})]},
])
inaccessible_records = PartnerCateg.create([
{'name': 'ua1'}, # No public parent
{'name': 'ua2', 'parent_id': pri_active.id},
{'name': 'ua3', 'parent_id': pub_active.id, 'child_ids': [Command.link(pri_active.id)]},
{'name': 'ua4', 'parent_id': pub_active.id, 'child_ids': [Command.link(pri_inactive.id)]},
])
records = accessible_records + inaccessible_records
domain = [('id', 'in', records.ids)]
PartnerCateg = PartnerCateg.with_user(self.env.ref('base.user_admin'))
PartnerCateg.search(domain) # warmup
with self.assertQueries(['''
SELECT "res_partner"."id"
FROM "res_partner"
LEFT JOIN "res_partner" AS "res_partner__commercial_partner_id"
ON ("res_partner"."commercial_partner_id" = "res_partner__commercial_partner_id"."id")
WHERE "res_partner"."active" IS TRUE AND (
("res_partner"."commercial_partner_id" IS NOT NULL AND "res_partner__commercial_partner_id"."name" IN %s)
AND EXISTS(SELECT FROM (
SELECT "res_users"."partner_id" AS __inverse
FROM "res_users"
WHERE "res_users"."login" LIKE %s
) AS __sub WHERE __inverse = "res_partner"."id")
)
ORDER BY "res_partner"."complete_name" ASC, "res_partner"."id" DESC
SELECT "res_partner_category"."id"
FROM "res_partner_category"
LEFT JOIN "res_partner_category" AS "res_partner_category__parent_id" ON (
"res_partner_category"."parent_id" = "res_partner_category__parent_id"."id")
WHERE ("res_partner_category"."active" IS TRUE AND "res_partner_category"."id" IN %s)
AND (NOT EXISTS(
SELECT FROM (
SELECT "res_partner_category"."parent_id" AS __inverse
FROM "res_partner_category"
WHERE
(
"res_partner_category"."name" ->> %s ILIKE %s
AND "res_partner_category"."parent_id" IS NOT NULL
)
) AS __sub
WHERE __inverse = "res_partner_category"."id"
)
AND (
"res_partner_category"."parent_id" IS NOT NULL
AND "res_partner_category__parent_id"."name" ->> %s ILIKE %s
)
)
ORDER BY "res_partner_category"."name" ->> %s, "res_partner_category"."id"
''']):
Model.search([])
records_search = PartnerCateg.search(domain)
self.assertEqual(records_search, accessible_records)
self.assertEqual(
records.with_user(self.env.ref('base.user_admin'))._filtered_access('read'),
accessible_records,
)
def test_access_rules_active_test_neg(self):
Model = self.env['res.partner'].with_user(self.env.ref('base.user_admin'))

View file

@ -221,9 +221,10 @@ class TestRequestRemainingAfterFirstCheck(TestRequestRemainingCommon):
s.get(self.base_url() + "/web/concurrent", timeout=10)
type(self).thread_a = threading.Thread(target=late_request_thread)
main_lock = self.main_lock
self.thread_a.start()
# we need to ensure that the first check is made and that we are aquiring the lock
self.main_lock.acquire()
main_lock.acquire()
def assertCanOpenTestCursor(self):
super().assertCanOpenTestCursor()

View file

@ -70,6 +70,28 @@ class TestIntervals(TransactionCase):
[(0, 5), (12, 13), (20, 22), (23, 24)],
)
def test_keep_distinct(self):
""" Test merge operations between two Intervals
instances with different _keep_distinct flags.
"""
A = Intervals(self.ints([(0, 10)]), keep_distinct=False)
B = Intervals(self.ints([(-5, 5), (5, 15)]), keep_distinct=True)
C = A & B
# The _keep_distinct flag must be the same as the left one
self.assertFalse(C._keep_distinct)
self.assertEqual(len(C), 1)
self.assertEqual(list(C), self.ints([(0, 10)]))
# If, as a result of the above operation, C has _keep_distinct = False
# but is not preserving its _items, the following operation must raise
# an error
D = Intervals()
C = C - D
self.assertFalse(C._keep_distinct)
self.assertEqual(C._items, self.ints([(0, 10)]))
class TestUtils(TransactionCase):

View file

@ -0,0 +1,77 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase, tagged
from odoo.tools import convert_file
from odoo.tools.misc import file_path
@tagged('-at_install', 'post_install')
class TestAsset(TransactionCase):
def test_asset_tag(self):
"""
Verify that assets defined with the <asset> tag are properly imported.
"""
# Load new records
convert_file(
self.env, 'base',
file_path('base/tests/asset_tag.xml'),
{}, 'init', False,
)
active_keep_asset = self.env.ref('base.test_asset_tag_aaa')
inactive_keep_asset = self.env.ref('base.test_asset_tag_iii')
active_switch_asset_reset = self.env.ref('base.test_asset_tag_aia')
active_switch_asset_ignore = self.env.ref('base.test_asset_tag_aii')
inactive_switch_asset = self.env.ref('base.test_asset_tag_iaa')
prepend_asset = self.env.ref('base.test_asset_tag_prepend')
asset_with_extra_field = self.env.ref('base.test_asset_tag_extra')
# Verify initial load
self.assertEqual(prepend_asset._name, 'ir.asset', 'Model should be ir.asset')
self.assertEqual(prepend_asset.name, 'Test asset tag with directive', 'Name not loaded')
self.assertEqual(prepend_asset.directive, 'prepend', 'Directive not loaded')
self.assertEqual(prepend_asset.bundle, 'test_asset_bundle', 'Bundle not loaded')
self.assertEqual(prepend_asset.path, 'base/tests/something.scss', 'Path not loaded')
self.assertEqual(asset_with_extra_field.sequence, 17, 'Sequence not loaded')
self.assertTrue(active_keep_asset.active, 'Should be active')
self.assertTrue(active_switch_asset_reset.active, 'Should be active')
self.assertTrue(active_switch_asset_ignore.active, 'Should be active')
self.assertFalse(inactive_keep_asset.active, 'Should be inactive')
self.assertFalse(inactive_switch_asset.active, 'Should be inactive')
# Patch records
prepend_asset.name = 'changed'
prepend_asset.directive = 'append'
prepend_asset.bundle = 'changed'
prepend_asset.path = 'base/tests/changed.scss'
asset_with_extra_field.sequence = 3
active_switch_asset_reset.active = False
active_switch_asset_ignore.active = False
inactive_switch_asset.active = True
# Update records
convert_file(
self.env, 'base',
file_path('base/tests/asset_tag.xml'),
{
'base.test_asset_tag_aaa': active_keep_asset.id,
'base.test_asset_tag_iii': inactive_keep_asset.id,
'base.test_asset_tag_aia': active_switch_asset_reset.id,
'base.test_asset_tag_aii': active_switch_asset_ignore.id,
'base.test_asset_tag_iaa': inactive_switch_asset.id,
'base.test_asset_tag_prepend': prepend_asset.id,
'base.test_asset_tag_extra': asset_with_extra_field.id,
}, 'update', False,
)
# Verify updated load
self.assertEqual(prepend_asset.name, 'Test asset tag with directive', 'Name not restored')
self.assertEqual(prepend_asset.directive, 'prepend', 'Directive not restored')
self.assertEqual(prepend_asset.bundle, 'test_asset_bundle', 'Bundle not restored')
self.assertEqual(prepend_asset.path, 'base/tests/something.scss', 'Path not restored')
self.assertEqual(asset_with_extra_field.sequence, 17, 'Sequence not restored')
self.assertTrue(active_keep_asset.active, 'Should be active')
self.assertTrue(active_switch_asset_reset.active, 'Should be reset to active')
self.assertFalse(active_switch_asset_ignore.active, 'Should be kept inactive')
self.assertFalse(inactive_keep_asset.active, 'Should be inactive')
self.assertTrue(inactive_switch_asset.active, 'Should be kept active')

View file

@ -114,6 +114,21 @@ class TestIrCron(TransactionCase, CronMixinCase):
self.assertEqual(self.cron.lastcall, fields.Datetime.now())
self.assertEqual(self.partner.name, 'You have been CRONWNED')
def test_cron_direct_trigger_exception(self):
self.cron.code = textwrap.dedent("raise UserError('oops')")
with (
self.enter_registry_test_mode(),
self.assertLogs('odoo.addons.base.models.ir_cron', 40), # logging.ERROR
self.registry.cursor() as cron_cr,
):
action = self.cron.with_env(self.env(cr=cron_cr)).method_direct_trigger()
self.assertNotEqual(action, True)
action_params = action.pop('params')
self.assertEqual(action, {'type': 'ir.actions.client', 'tag': 'display_exception'})
self.assertEqual(list(action_params), ['code', 'message', 'data'])
self.assertEqual(list(action_params['data']), ['name', 'message', 'arguments', 'context', 'debug'])
def test_cron_no_job_ready(self):
self.cron.nextcall = fields.Datetime.now() + timedelta(days=1)
self.cron.flush_recordset()

View file

@ -475,7 +475,7 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
)
def test_eml_attachment_encoding(self):
"""Test that message/rfc822 attachments are encoded using 7bit, 8bit, or binary encoding."""
"""Test that message/rfc822 attachments are encoded using 7bit, 8bit, or binary encoding per RFC."""
IrMailServer = self.env['ir.mail_server']
# Create a sample .eml file content
@ -491,12 +491,43 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
attachments=attachments,
)
# Verify that the attachment is correctly encoded
acceptable_encodings = {'7bit', '8bit', 'binary'}
found_rfc822_part = False
for part in message.iter_attachments():
if part.get_content_type() == 'message/rfc822':
found_rfc822_part = True
# Get Content-Transfer-Encoding, defaulting to '7bit' if not present (per RFC)
encoding = part.get('Content-Transfer-Encoding', '7bit').lower()
self.assertIn(
part.get('Content-Transfer-Encoding'),
encoding,
acceptable_encodings,
"The message/rfc822 attachment should be encoded using 7bit, 8bit, or binary encoding.",
f"RFC violation: message/rfc822 attachment has Content-Transfer-Encoding '{encoding}'. "
f"Only 7bit, 8bit, or binary encoding is permitted per RFC 2046 Section 5.2.1."
)
self.assertTrue(found_rfc822_part, "No message/rfc822 attachment found in the built email")
def test_eml_message_serialization_with_non_ascii(self):
"""Ensure an email with a message/rfc822 attachment containing non-ASCII chars can be serialized."""
IrMailServer = self.env['ir.mail_server']
# .eml content with non-ASCII character
eml_content = "From: user@example.com\nTo: user2@example.com\nSubject: Test\n\nBody with é"
attachments = [('test.eml', eml_content.encode(), 'message/rfc822')]
message = IrMailServer._build_email__(
email_from='john.doe@from.example.com',
email_to='destinataire@to.example.com',
subject='Serialization test',
body='This email contains a .eml attachment.',
attachments=attachments,
)
try:
serialized = message.as_string().encode('utf-8')
except UnicodeEncodeError as e:
raise AssertionError("Email with non-ASCII .eml attachment could not be serialized") from e
self.assertIsInstance(serialized, bytes)

View file

@ -27,6 +27,7 @@ except ImportError:
aiosmtpd = None
SMTP_TIMEOUT = 5
PASSWORD = 'secretpassword'
_openssl = shutil.which('openssl')
_logger = logging.getLogger(__name__)
@ -68,7 +69,7 @@ class Certificate:
@unittest.skipUnless(aiosmtpd, "aiosmtpd couldn't be imported")
@unittest.skipUnless(_openssl, "openssl not found in path")
# fail fast for timeout errors
@patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', .1)
@patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', SMTP_TIMEOUT)
# prevent the CLI from interfering with the tests
@patch.dict(config.options, {'smtp_server': ''})
class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
@ -146,8 +147,8 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
# when resolving "localhost" (so stupid), use the following to
# force aiosmtpd/odoo to bind/connect to a fixed ipv4 OR ipv6
# address.
family, _, cls.port = _find_free_local_address()
cls.localhost = getaddrinfo('localhost', cls.port, family)
family, addr, cls.port = _find_free_local_address()
cls.localhost = getaddrinfo(addr, cls.port, family)
cls.startClassPatcher(patch('socket.getaddrinfo', cls.getaddrinfo))
def setUp(self):
@ -268,13 +269,14 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
'smtp_ssl_private_key': private_key,
})
if error_pattern:
with self.assertRaises(UserError) as error_capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as error_capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(error_capture.exception.args[0], error_pattern)
else:
mail_server.test_smtp_connection()
def test_authentication_login_matrix(self):
"""
Connect to a server that is authenticating users via a login/pwd
@ -318,7 +320,9 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
password=password):
with self.start_smtpd(encryption, ssl_context, auth_required):
if error_pattern:
with self.assertRaises(UserError) as capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(capture.exception.args[0], error_pattern)
else:
@ -374,7 +378,9 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
client_encryption=client_encryption):
mail_server.smtp_encryption = client_encryption
with self.start_smtpd(server_encryption, ssl_context, auth_required=False):
with self.assertRaises(UserError) as capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(capture.exception.args[0], error_pattern)

View file

@ -221,7 +221,7 @@ class TestIrSequenceGenerate(BaseCase):
isoyear, isoweek, weekday = datetime.now().isocalendar()
self.assertEqual(
env['ir.sequence'].next_by_code('test_sequence_type_9'),
f"{isoyear}/{isoyear % 100}/1/{isoweek}/{weekday % 7}",
f"{isoyear}/{isoyear % 100:02d}/1/{isoweek:02d}/{weekday % 7}",
)
def test_ir_sequence_suffix(self):

View file

@ -402,13 +402,15 @@ class TestHtmlTools(BaseCase):
def test_plaintext2html(self):
cases = [
("First \nSecond \nThird\n \nParagraph\n\r--\nSignature paragraph", 'div',
("First \nSecond \nThird\n \nParagraph\n\r--\nSignature paragraph", 'div', True,
"<div><p>First <br/>Second <br/>Third</p><p>Paragraph</p><p>--<br/>Signature paragraph</p></div>"),
("First<p>It should be escaped</p>\nSignature", False,
"<p>First&lt;p&gt;It should be escaped&lt;/p&gt;<br/>Signature</p>")
("First<p>It should be escaped</p>\nSignature", False, True,
"<p>First&lt;p&gt;It should be escaped&lt;/p&gt;<br/>Signature</p>"),
("First \nSecond \nThird", False, False,
"First <br/>Second <br/>Third"),
]
for content, container_tag, expected in cases:
html = plaintext2html(content, container_tag)
for content, container_tag, with_paragraph, expected in cases:
html = plaintext2html(content, container_tag, with_paragraph)
self.assertEqual(html, expected, 'plaintext2html is broken')
def test_html_html_to_inner_content(self):

Some files were not shown because too many files have changed in this diff Show more