mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 07:32:04 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
|
@ -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"
|
||||
|
|
|
|||
|
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
56939
odoo-bringout-oca-ocb-base/odoo/addons/base/i18n/uz.po
Normal file
56939
odoo-bringout-oca-ocb-base/odoo/addons/base/i18n/uz.po
Normal file
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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ FLAG_MAPPING = {
|
|||
"RE": "fr",
|
||||
"MF": "fr",
|
||||
"UM": "us",
|
||||
"XI": "uk",
|
||||
}
|
||||
|
||||
NO_FLAG_COUNTRIES = [
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("""
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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<p>It should be escaped</p><br/>Signature</p>")
|
||||
("First<p>It should be escaped</p>\nSignature", False, True,
|
||||
"<p>First<p>It should be escaped</p><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
Loading…
Add table
Add a link
Reference in a new issue